Arthur 1 год назад
Родитель
Сommit
13bf2c1940
100 измененных файлов с 1135 добавлено и 675 удалено
  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
   - type: dropdown
     attributes:
     attributes:
       label: Deployment Type
       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:
       options:
         - Self-hosted
         - Self-hosted
         - NetBox Cloud
         - NetBox Cloud
@@ -23,7 +25,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.7.2
+      placeholder: v3.7.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.7.2
+      placeholder: v3.7.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - 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/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/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://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>
   <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>
   <p></p>
 </div>
 </div>

+ 1 - 1
base_requirements.txt

@@ -100,7 +100,7 @@ mkdocs-material
 mkdocstrings[python-legacy]
 mkdocstrings[python-legacy]
 
 
 # Library for manipulating IP prefixes and addresses
 # 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
 netaddr
 
 
 # Python bindings to the ammonia HTML sanitization library.
 # 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)
 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.
 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
 ### Device Type
 
 

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

@@ -1,6 +1,41 @@
 # NetBox v3.7
 # 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.
 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
 ### Enhancements
 
 
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
 * [#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
 * [#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
 * [#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
 * [#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
 ### 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`
 * [#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()`)
 * [#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
 * [#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
 * [#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
 * [#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 circuits.models import *
 from dcim.api.nested_serializers import NestedSiteSerializer
 from dcim.api.nested_serializers import NestedSiteSerializer
 from dcim.api.serializers import CabledObjectSerializer
 from dcim.api.serializers import CabledObjectSerializer
-from ipam.models import ASN
 from ipam.api.nested_serializers import NestedASNSerializer
 from ipam.api.nested_serializers import NestedASNSerializer
+from ipam.models import ASN
 from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
             'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
             'custom_fields', 'created', 'last_updated', 'circuit_count',
             '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',
             'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated',
             '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',
             'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
             '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',
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'circuit_count',
             'last_updated', 'circuit_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
 
 
 
 
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@@ -122,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
             'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
             'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
             'custom_fields', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'cid', 'description')
 
 
 
 
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@@ -137,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
             'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             '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"
     verbose_name = "Circuits"
 
 
     def ready(self):
     def ready(self):
+        from netbox.models.features import register_models
         from . import signals, search
         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
         # Must define either site *or* provider network
         if self.site is None and self.provider_network is None:
         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:
         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):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(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):
 class ProviderTest(APIViewTestCases.APIViewTestCase):
     model = Provider
     model = Provider
-    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'comments': 'New comments',
         'comments': 'New comments',
     }
     }
@@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
 class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
     model = CircuitType
     model = CircuitType
-    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = (
     create_data = (
         {
         {
             'name': 'Circuit Type 4',
             'name': 'Circuit Type 4',
@@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTest(APIViewTestCases.APIViewTestCase):
 class CircuitTest(APIViewTestCases.APIViewTestCase):
     model = Circuit
     model = Circuit
-    brief_fields = ['cid', 'display', 'id', 'url']
+    brief_fields = ['cid', 'description', 'display', 'id', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
 class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
     model = CircuitTermination
     model = CircuitTermination
-    brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
+    brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
 
 
 class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
 class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
     model = ProviderAccount
     model = ProviderAccount
-    brief_fields = ['account', 'display', 'id', 'name', 'url']
+    brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
 
 
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
     model = ProviderNetwork
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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,
     build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
 )
 )
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
+from rest_framework import serializers
 from rest_framework.relations import ManyRelatedField
 from rest_framework.relations import ManyRelatedField
 
 
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 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',
             'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
             'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
             'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class DataFileSerializer(NetBoxModelSerializer):
 class DataFileSerializer(NetBoxModelSerializer):
@@ -51,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
             'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'path')
 
 
 
 
 class JobSerializer(BaseModelSerializer):
 class JobSerializer(BaseModelSerializer):
@@ -69,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
             'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
             'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
             'started', 'completed', 'user', 'data', 'error', 'job_id',
             '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"
     name = "core"
 
 
     def ready(self):
     def ready(self):
+        from core.api import schema  # noqa
+        from netbox.models.features import register_models
         from . import data_backends, search
         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:
         try:
             porcelain.clone(self.url, local_path.name, **clone_args)
             porcelain.clone(self.url, local_path.name, **clone_args)
         except BaseException as e:
         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
         yield local_path.name
 
 

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

@@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
         super().clean()
         super().clean()
 
 
         if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
         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'):
         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
         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)
         return gettext('Config revision #{id}').format(id=self.pk)
 
 
     def __getattr__(self, item):
     def __getattr__(self, item):
-        if item in self.data:
+        if self.data and item in self.data:
             return self.data[item]
             return self.data[item]
         return super().__getattribute__(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.
         Create/update/delete child DataFiles as necessary to synchronize with the remote source.
         """
         """
         if self.status == DataSourceStatusChoices.SYNCING:
         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
         # Emit the pre_sync signal
         pre_sync.send(sender=self.__class__, instance=self)
         pre_sync.send(sender=self.__class__, instance=self)
@@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
             backend = self.get_backend()
             backend = self.get_backend()
         except ModuleNotFoundError as e:
         except ModuleNotFoundError as e:
             raise SyncError(
             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:
         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
         valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
         if status not in valid_statuses:
         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
         # Mark the job as completed
         self.status = status
         self.status = status

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

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

+ 1 - 1
netbox/core/views.py

@@ -184,7 +184,7 @@ class ConfigView(generic.ObjectView):
         except ConfigRevision.DoesNotExist:
         except ConfigRevision.DoesNotExist:
             # Fall back to using the active config data if no record is found
             # Fall back to using the active config data if no record is found
             return ConfigRevision(
             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',
             'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
             'last_updated', 'site_count', '_depth',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
 
 
 class SiteGroupSerializer(NestedGroupModelSerializer):
 class SiteGroupSerializer(NestedGroupModelSerializer):
@@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
             'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
             'last_updated', 'site_count', '_depth',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
 
 
 class SiteSerializer(NetBoxModelSerializer):
 class SiteSerializer(NetBoxModelSerializer):
@@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
             'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
             'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
             'virtualmachine_count', 'vlan_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',
             'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
             'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
 
 
 
 
 class RackRoleSerializer(NetBoxModelSerializer):
 class RackRoleSerializer(NetBoxModelSerializer):
@@ -194,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'rack_count',
             'last_updated', 'rack_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
 
 
 
 
 class RackSerializer(NetBoxModelSerializer):
 class RackSerializer(NetBoxModelSerializer):
@@ -222,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
             'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
             'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
             'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 
 
 
 
 class RackUnitSerializer(serializers.Serializer):
 class RackUnitSerializer(serializers.Serializer):
@@ -256,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
             'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
             'comments', 'tags', 'custom_fields',
             'comments', 'tags', 'custom_fields',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
 
 
 
 
 class RackElevationDetailFilterSerializer(serializers.Serializer):
 class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -315,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
 
 
 
 
 class DeviceTypeSerializer(NetBoxModelSerializer):
 class DeviceTypeSerializer(NetBoxModelSerializer):
@@ -331,6 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
+    front_image = serializers.URLField(allow_null=True, required=False)
+    rear_image = serializers.URLField(allow_null=True, required=False)
 
 
     # Counter fields
     # Counter fields
     console_port_template_count = serializers.IntegerField(read_only=True)
     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',
             'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
             'inventory_item_template_count',
             'inventory_item_template_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 
 
 
 
 class ModuleTypeSerializer(NetBoxModelSerializer):
 class ModuleTypeSerializer(NetBoxModelSerializer):
@@ -371,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
             'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
             'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             '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',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
             'last_updated',
             'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -427,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
             'last_updated',
             'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -454,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
             'allocated_draw', 'description', 'created', 'last_updated',
             'allocated_draw', 'description', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@@ -491,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
             'description', 'created', 'last_updated',
             'description', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -535,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
             'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
             'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class RearPortTemplateSerializer(ValidatedModelSerializer):
 class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -557,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
             'description', 'created', 'last_updated',
             'description', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class FrontPortTemplateSerializer(ValidatedModelSerializer):
 class FrontPortTemplateSerializer(ValidatedModelSerializer):
@@ -580,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
             'rear_port_position', 'description', 'created', 'last_updated',
             'rear_port_position', 'description', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class ModuleBayTemplateSerializer(ValidatedModelSerializer):
 class ModuleBayTemplateSerializer(ValidatedModelSerializer):
@@ -592,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
             'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
             'last_updated',
             'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@@ -601,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
         fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
 class InventoryItemTemplateSerializer(ValidatedModelSerializer):
 class InventoryItemTemplateSerializer(ValidatedModelSerializer):
@@ -627,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
             'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
             'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
             '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))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
     def get_component(self, obj):
@@ -655,6 +677,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
             'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
 
 
 
 
 class PlatformSerializer(NetBoxModelSerializer):
 class PlatformSerializer(NetBoxModelSerializer):
@@ -672,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
             'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
 
 
 
 
 class DeviceSerializer(NetBoxModelSerializer):
 class DeviceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     role = NestedDeviceRoleSerializer()
     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)
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
@@ -720,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         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)
     @extend_schema_field(NestedDeviceSerializer)
     def get_parent_device(self, obj):
     def get_parent_device(self, obj):
@@ -740,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         return data
         return data
 
 
-    def get_device_role(self, obj):
-        return obj.role
-
 
 
 class DeviceWithConfigContextSerializer(DeviceSerializer):
 class DeviceWithConfigContextSerializer(DeviceSerializer):
     config_context = serializers.SerializerMethodField(read_only=True)
     config_context = serializers.SerializerMethodField(read_only=True)
 
 
     class Meta(DeviceSerializer.Meta):
     class Meta(DeviceSerializer.Meta):
         fields = [
         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))
     @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',
             'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'interface_count',
             'interface_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
 
 
 
 
 class ModuleSerializer(NetBoxModelSerializer):
 class ModuleSerializer(NetBoxModelSerializer):
@@ -797,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
             'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             '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',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
             'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -857,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
             'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 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',
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
             'created', 'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 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',
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
             'created', 'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -977,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
             'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
     def validate(self, data):
     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',
             'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
             'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class FrontPortRearPortSerializer(WritableNestedSerializer):
 class FrontPortRearPortSerializer(WritableNestedSerializer):
@@ -1038,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
             'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
 class ModuleBaySerializer(NetBoxModelSerializer):
 class ModuleBaySerializer(NetBoxModelSerializer):
@@ -1049,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
         model = ModuleBay
         model = ModuleBay
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
             '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):
 class DeviceBaySerializer(NetBoxModelSerializer):
@@ -1065,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
             'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
             'custom_fields', 'created', 'last_updated',
             'custom_fields', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
 
 
 
 
 class InventoryItemSerializer(NetBoxModelSerializer):
 class InventoryItemSerializer(NetBoxModelSerializer):
@@ -1088,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
             'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
             'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
             'custom_fields', 'created', 'last_updated', '_depth',
             'custom_fields', 'created', 'last_updated', '_depth',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
     def get_component(self, obj):
@@ -1114,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'inventoryitem_count',
             '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',
             'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
             'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'label', 'description')
 
 
 
 
 class TracedCableSerializer(serializers.ModelSerializer):
 class TracedCableSerializer(serializers.ModelSerializer):
@@ -1204,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
             'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'member_count',
             'created', 'last_updated', 'member_count',
         ]
         ]
+        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',
             'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
             'powerfeed_count', 'created', 'last_updated',
             'powerfeed_count', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
 
 
 
 
 class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -1267,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
             'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             '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
     serializer_class = serializers.RackSerializer
     filterset_class = filtersets.RackFilterSet
     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)
     @action(detail=True)
     def elevation(self, request, pk=None):
     def elevation(self, request, pk=None):
         """
         """
@@ -372,12 +378,8 @@ class DeviceViewSet(
 
 
         Else, return the DeviceWithConfigContextSerializer
         Else, return the DeviceWithConfigContextSerializer
         """
         """
-
         request = self.get_serializer_context()['request']
         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.DeviceSerializer
 
 
         return serializers.DeviceWithConfigContextSerializer
         return serializers.DeviceWithConfigContextSerializer

+ 5 - 1
netbox/dcim/apps.py

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

+ 3 - 2
netbox/dcim/fields.py

@@ -1,6 +1,7 @@
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
+from django.utils.translation import gettext as _
 from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
 from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
 
 
 from .lookups import PathContains
 from .lookups import PathContains
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
         try:
         try:
             return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
             return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
         except AddrFormatError:
         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):
     def db_type(self, connection):
         return 'macaddr'
         return 'macaddr'
@@ -67,7 +68,7 @@ class WWNField(models.Field):
         try:
         try:
             return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
             return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
         except AddrFormatError:
         except AddrFormatError:
-            raise ValidationError(f"Invalid WWN format: {value}")
+            raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
 
 
     def db_type(self, connection):
     def db_type(self, connection):
         return 'macaddr8'
         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.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 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 circuits.models import CircuitTermination
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Manufacturer (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(
     config_template_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
         label=_('Config template (ID)'),
         label=_('Config template (ID)'),
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         model = Platform
         model = Platform
         fields = ['id', 'name', 'slug', 'description']
         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(
 class DeviceFilterSet(
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,

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

@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
         model = Location
         model = Location
         fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
         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):
 class RackRoleImportForm(NetBoxModelImportForm):
     slug = SlugField()
     slug = SlugField()
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
     def clean_vdcs(self):
     def clean_vdcs(self):
         for vdc in self.cleaned_data['vdcs']:
         for vdc in self.cleaned_data['vdcs']:
             if vdc.device != self.cleaned_data['device']:
             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']
         return self.cleaned_data['vdcs']
 
 
 
 
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
                 device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
                 device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
             ).exclude(pk=device.pk)
             ).exclude(pk=device.pk)
         else:
         else:
-            self.fields['installed_device'].queryset = Interface.objects.none()
+            self.fields['installed_device'].queryset = Device.objects.none()
 
 
 
 
 class InventoryItemImportForm(NetBoxModelImportForm):
 class InventoryItemImportForm(NetBoxModelImportForm):
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
             component = model.objects.get(device=device, name=component_name)
             component = model.objects.get(device=device, name=component_name)
             self.instance.component = component
             self.instance.component = component
         except ObjectDoesNotExist:
         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:
             else:
                 termination_object = model.objects.get(device=device, name=name)
                 termination_object = model.objects.get(device=device, name=name)
             if termination_object.cable is not None and termination_object.cable != self.instance:
             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:
         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])
         setattr(self.instance, f'{side}_terminations', [termination_object])
         return termination_object
         return termination_object
 
 

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

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

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

@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
 
 
         # Validate length and length_unit
         # Validate length and length_unit
         if self.length is not None and not self.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):
         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:
         if self._terminations_modified:
 
 
             # Check that all termination objects for either end are of the same type
             # Check that all termination objects for either end are of the same type
             for terms in (self.a_terminations, self.b_terminations):
             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:]):
                 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
             # Check that termination types are compatible
             if self.a_terminations and self.b_terminations:
             if self.a_terminations and self.b_terminations:
                 a_type = self.a_terminations[0]._meta.model_name
                 a_type = self.a_terminations[0]._meta.model_name
                 b_type = self.b_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):
                 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:
                 if a_type == b_type:
                     # can't directly use self.a_terminations here as possible they
                     # can't directly use self.a_terminations here as possible they
                     # don't have pk yet
                     # don't have pk yet
@@ -327,17 +328,24 @@ class CableTermination(ChangeLoggedModel):
         existing_termination = qs.first()
         existing_termination = qs.first()
         if existing_termination is not None:
         if existing_termination is not None:
             raise ValidationError(
             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)
         # Validate interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
         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
         # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
         if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
         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):
     def save(self, *args, **kwargs):
 
 

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

@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
         super().clean()
         super().clean()
 
 
         # Validate that the parent Device can have DeviceBays
         # 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(
             raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
                 device_type=self.device.device_type
                 device_type=self.device.device_type
             ))
             ))
 
 
         # Cannot install a device into itself, obviously
         # 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."))
             raise ValidationError(_("Cannot install a device into itself."))
 
 
         # Check that the installed device is not already installed elsewhere
         # 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):
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
         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):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -875,7 +861,7 @@ class Device(
             if self.position and self.device_type.u_height == 0:
             if self.position and self.device_type.u_height == 0:
                 raise ValidationError({
                 raise ValidationError({
                     'position': _(
                     '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)
                     ).format(device_type=self.device_type)
                 })
                 })
 
 

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

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

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

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

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

@@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
         device = Device.objects.create(
         device = Device.objects.create(
             site=self.site,
             site=self.site,
             device_type=self.device.device_type,
             device_type=self.device.device_type,
-            device_role=self.device.device_role,
+            role=self.device.role,
             name='Test mid-span Device'
             name='Test mid-span Device'
         )
         )
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         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 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
             Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
             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 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
+            Platform(name='Platform 4', slug='platform-4'),
         )
         )
         Platform.objects.bulk_create(platforms)
         Platform.objects.bulk_create(platforms)
 
 
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()
     queryset = Device.objects.all()

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

@@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
         device2.full_clean()
         device2.full_clean()
         device2.save()
         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):
 class CableTestCase(TestCase):
 
 

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

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
 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.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from rest_framework.fields import Field
 from rest_framework.fields import Field
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
                 if serializer.is_valid():
                 if serializer.is_valid():
                     data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
                     data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
                 else:
                 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 updating an existing instance, start with existing custom_field_data
         if self.parent.instance:
         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.contrib.auth import get_user_model
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
@@ -43,9 +44,6 @@ __all__ = (
     'ImageAttachmentSerializer',
     'ImageAttachmentSerializer',
     'JournalEntrySerializer',
     'JournalEntrySerializer',
     'ObjectChangeSerializer',
     'ObjectChangeSerializer',
-    'ReportDetailSerializer',
-    'ReportSerializer',
-    'ReportInputSerializer',
     'SavedFilterSerializer',
     'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     'ScriptInputSerializer',
@@ -78,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
             'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
             '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',
             'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
     @extend_schema_field(OpenApiTypes.OBJECT)
     @extend_schema_field(OpenApiTypes.OBJECT)
     def get_action_object(self, instance):
     def get_action_object(self, instance):
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         # We need to manually instantiate the serializer for scripts
         # We need to manually instantiate the serializer for scripts
         if instance.action_type == EventRuleActionChoices.SCRIPT:
         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:
         else:
             serializer = get_serializer_for_model(
             serializer = get_serializer_for_model(
                 model=instance.action_object_type.model_class(),
                 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',
             'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
             'tags', 'created', 'last_updated',
             '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',
             'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
             'created', 'last_updated',
             'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
     def validate_type(self, value):
     def validate_type(self, value):
         if self.instance and self.instance.type != 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
         return value
 
 
@@ -186,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
             'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
             'choices_count', 'created', 'last_updated',
             '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',
             'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
             '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',
             'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
             'last_updated',
             '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',
             'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
             'shared', 'parameters', 'created', 'last_updated',
             'shared', 'parameters', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
 
 
 
 
 #
 #
@@ -269,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
             '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))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, instance):
     def get_object(self, instance):
@@ -297,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
             'last_updated',
             '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',
             'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
             'image_width', 'created', 'last_updated',
             'image_width', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'image')
 
 
     def validate(self, data):
     def validate(self, data):
 
 
@@ -365,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
             'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
             'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
             'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'created')
 
 
     def validate(self, data):
     def validate(self, data):
 
 
@@ -488,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
             'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
             'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
             'created', 'last_updated',
             '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',
             'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
             'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
             '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
 # 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)
     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))
     @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())
     @extend_schema_field(serializers.CharField())
     def get_display(self, obj):
     def get_display(self, obj):
         return f'{obj.name} ({obj.module})'
         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):
 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):
 class ScriptInputSerializer(serializers.Serializer):
@@ -594,12 +581,12 @@ class ScriptInputSerializer(serializers.Serializer):
 
 
     def validate_schedule_at(self, value):
     def validate_schedule_at(self, value):
         if value and not self.context['script'].scheduling_enabled:
         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
         return value
 
 
     def validate_interval(self, value):
     def validate_interval(self, value):
         if value and not self.context['script'].scheduling_enabled:
         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
         return value
 
 
 
 

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

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
@@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.renderers import JSONRenderer
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 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 rq import Worker
 
 
-from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras import filtersets
 from extras import filtersets
 from extras.models import *
 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.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
@@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
 # Scripts
 # Scripts
 #
 #
 
 
-class ScriptViewSet(ViewSet):
+class ScriptViewSet(ModelViewSet):
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
+    queryset = Script.objects.prefetch_related('jobs')
+    serializer_class = serializers.ScriptSerializer
+    filterset_class = filtersets.ScriptFilterSet
+
     _ignore_model_permissions = True
     _ignore_model_permissions = True
-    schema = None
     lookup_value_regex = '[^/]+'  # Allow dots
     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):
     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})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)
 
 
     def post(self, request, pk):
     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'):
         if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run scripts.")
             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(
         input_serializer = serializers.ScriptInputSerializer(
             data=request.data,
             data=request.data,
             context={'script': script}
             context={'script': script}
@@ -281,13 +243,13 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
         if input_serializer.is_valid():
             script.result = Job.enqueue(
             script.result = Job.enqueue(
                 run_script,
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.python_class.class_name,
                 user=request.user,
                 user=request.user,
                 data=input_serializer.data['data'],
                 data=input_serializer.data['data'],
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),
                 commit=input_serializer.data['commit'],
                 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'),
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
                 interval=input_serializer.validated_data.get('interval')
             )
             )

+ 4 - 0
netbox/extras/apps.py

@@ -5,4 +5,8 @@ class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
+        from netbox.models.features import register_models
         from . import dashboard, lookups, search, signals
         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 functools
 import re
 import re
+from django.utils.translation import gettext as _
 
 
 __all__ = (
 __all__ = (
     'Condition',
     'Condition',
@@ -50,11 +51,13 @@ class Condition:
 
 
     def __init__(self, attr, value, op=EQ, negate=False):
     def __init__(self, attr, value, op=EQ, negate=False):
         if op not in self.OPERATORS:
         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:
         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)]:
         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.attr = attr
         self.value = value
         self.value = value
@@ -131,14 +134,17 @@ class ConditionSet:
     """
     """
     def __init__(self, ruleset):
     def __init__(self, ruleset):
         if type(ruleset) is not dict:
         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:
         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
         # Determine the logic type
         logic = list(ruleset.keys())[0]
         logic = list(ruleset.keys())[0]
         if type(logic) is not str or logic.lower() not in (AND, OR):
         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()
         self.logic = logic.lower()
 
 
         # Compile the set of Conditions
         # 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.conf import settings
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 from extras.constants import DEFAULT_DASHBOARD
 from extras.constants import DEFAULT_DASHBOARD
@@ -32,7 +33,7 @@ def get_widget_class(name):
     try:
     try:
         return registry['widgets'][name]
         return registry['widgets'][name]
     except KeyError:
     except KeyError:
-        raise ValueError(f"Unregistered widget class: {name}")
+        raise ValueError(_("Unregistered widget class: {name}").format(name=name))
 
 
 
 
 def get_dashboard(user):
 def get_dashboard(user):

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

@@ -111,7 +111,9 @@ class DashboardWidget:
         Params:
         Params:
             request: The current request
             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
     @property
     def name(self):
     def name(self):
@@ -177,7 +179,7 @@ class ObjectCountsWidget(DashboardWidget):
                 try:
                 try:
                     dict(data)
                     dict(data)
                 except TypeError:
                 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
             return data
 
 
     def render(self, request):
     def render(self, request):
@@ -231,7 +233,7 @@ class ObjectListWidget(DashboardWidget):
                 try:
                 try:
                     urlencode(data)
                     urlencode(data)
                 except (TypeError, ValueError):
                 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
             return data
 
 
     def render(self, request):
     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.core.exceptions import ObjectDoesNotExist
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext as _
 from django_rq import get_queue
 from django_rq import get_queue
 
 
 from core.models import Job
 from core.models import Job
@@ -115,21 +116,21 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
         # Scripts
         # Scripts
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
             # Resolve the script from action parameters
             # 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
             # Enqueue a Job to record the script's execution
             Job.enqueue(
             Job.enqueue(
                 "extras.scripts.run_script",
                 "extras.scripts.run_script",
-                instance=script_module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.name,
                 user=user,
                 user=user,
                 data=data
                 data=data
             )
             )
 
 
         else:
         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):
 def process_event_queue(events):
@@ -175,4 +176,4 @@ def flush_events(queue):
                 func = import_string(name)
                 func = import_string(name)
                 func(queue)
                 func(queue)
             except Exception as e:
             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',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
     'SavedFilterFilterSet',
     'SavedFilterFilterSet',
+    'ScriptFilterSet',
     'TagFilterSet',
     'TagFilterSet',
     'WebhookFilterSet',
     '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):
 class WebhookFilterSet(NetBoxModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 4 - 7
netbox/extras/forms/bulk_import.py

@@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 try:
                 try:
                     webhook = Webhook.objects.get(name=action_object)
                     webhook = Webhook.objects.get(name=action_object)
                 except Webhook.DoesNotExist:
                 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
                 self.instance.action_object = webhook
             # Script
             # Script
             elif action_type == EventRuleActionChoices.SCRIPT:
             elif action_type == EventRuleActionChoices.SCRIPT:
@@ -211,12 +211,9 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 try:
                 try:
                     module, script = get_module_and_script(module_name, script_name)
                     module, script = get_module_and_script(module_name, script_name)
                 except ObjectDoesNotExist:
                 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):
 class TagImportForm(CSVModelForm):

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

@@ -297,20 +297,16 @@ class EventRuleForm(NetBoxModelForm):
         }
         }
 
 
     def init_script_choice(self):
     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):
     def init_webhook_choice(self):
         initial = None
         initial = None
@@ -348,26 +344,13 @@ class EventRuleForm(NetBoxModelForm):
         # Script
         # Script
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
             self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
             self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
-                ScriptModule,
+                Script,
                 for_concrete_model=False
                 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
         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):
 class TagForm(forms.ModelForm):
     slug = SlugField()
     slug = SlugField()

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

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
@@ -62,7 +63,7 @@ class Command(BaseCommand):
         # Determine which models to reindex
         # Determine which models to reindex
         indexers = self._get_indexers(*model_labels)
         indexers = self._get_indexers(*model_labels)
         if not indexers:
         if not indexers:
-            raise CommandError("No indexers found!")
+            raise CommandError(_("No indexers found!"))
         self.stdout.write(f'Reindexing {len(indexers)} models.')
         self.stdout.write(f'Reindexing {len(indexers)} models.')
 
 
         # Clear all cached values for the specified models (if not being lazy)
         # 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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('extras', '0106_bookmark_user_cascade_deletion'),
+        ('extras', '0107_cachedvalue_extras_cachedvalue_object'),
     ]
     ]
 
 
     operations = [
     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',
         ct_field='action_object_type',
         fk_field='action_object_id'
         fk_field='action_object_id'
     )
     )
-    action_parameters = models.JSONField(
-        blank=True,
-        null=True
-    )
     action_data = models.JSONField(
     action_data = models.JSONField(
         verbose_name=_('data'),
         verbose_name=_('data'),
         blank=True,
         blank=True,

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

@@ -2,8 +2,11 @@ import inspect
 import logging
 import logging
 from functools import cached_property
 from functools import cached_property
 
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.db.models import Q
 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -22,12 +25,63 @@ __all__ = (
 logger = logging.getLogger('netbox.data_backends')
 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:
     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)):
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
@@ -55,7 +109,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
         return self.python_name
         return self.python_name
 
 
     @cached_property
     @cached_property
-    def scripts(self):
+    def module_scripts(self):
 
 
         def _get_name(cls):
         def _get_name(cls):
             # For child objects in submodules use the full import path w/o the root module as the name
             # 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
         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):
     def save(self, *args, **kwargs):
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         return super().save(*args, **kwargs)
         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')
         ordering = ('weight', 'object_type', 'value', 'object_id')
         verbose_name = _('cached value')
         verbose_name = _('cached value')
         verbose_name_plural = _('cached values')
         verbose_name_plural = _('cached values')
+        indexes = (
+            models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
         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):
 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.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras.choices import LogLevelChoices
 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 extras.signals import clear_events
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -411,11 +411,11 @@ class BaseScript:
             fieldsets.extend(self.fieldsets)
             fieldsets.extend(self.fieldsets)
         else:
         else:
             fields = list(name for name, _ in self._get_vars().items())
             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
         # Append the default fieldset if defined in the Meta class
         exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
         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
         return fieldsets
 
 
@@ -582,7 +582,7 @@ def is_variable(obj):
 
 
 def get_module_and_script(module_name, script_name):
 def get_module_and_script(module_name, script_name):
     module = ScriptModule.objects.get(file_path=f'{module_name}.py')
     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
     return module, script
 
 
 
 
@@ -599,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     """
     """
     job.start()
     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 = logging.getLogger(f"netbox.scripts.{script.full_name}")
     logger.info(f"Running script (commit={commit})")
     logger.info(f"Running script (commit={commit})")

+ 38 - 34
netbox/extras/signals.py

@@ -1,8 +1,8 @@
-import importlib
 import logging
 import logging
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 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.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django.dispatch import receiver, Signal
 from django.utils.translation import gettext_lazy as _
 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.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.events import process_event_rules
 from extras.models import EventRule
 from extras.models import EventRule
-from extras.validators import CustomValidator
+from extras.validators import run_validators
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
+from netbox.models.features import ChangeLoggingMixin
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from .choices import ObjectChangeActionChoices
 from .choices import ObjectChangeActionChoices
@@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
     else:
     else:
         return
         return
 
 
-    # Create/update an ObejctChange record for this change
+    # Create/update an ObjectChange record for this change
     objectchange = instance.to_objectchange(action)
     objectchange = instance.to_objectchange(action)
     # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
     # 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
     # 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.
     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
     # Get the current request, or bail if not set
     request = current_request.get()
     request = current_request.get()
     if request is None:
     if request is None:
@@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
         objectchange.request_id = request.id
         objectchange.request_id = request.id
         objectchange.save()
         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
     # Enqueue webhooks
     queue = events_queue.get()
     queue = events_queue.get()
     enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
     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
 # 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)
 @receiver(post_clean)
 def run_save_validators(sender, instance, **kwargs):
 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}'
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
     validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
 
 
     run_validators(instance, validators)
     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
 # 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.choices import *
 from extras.models import *
 from extras.models import *
 from extras.reports import Report
 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
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 User = get_user_model()
 User = get_user_model()
@@ -29,7 +29,7 @@ class AppTest(APITestCase):
 
 
 class WebhookTest(APIViewTestCases.APIViewTestCase):
 class WebhookTest(APIViewTestCases.APIViewTestCase):
     model = Webhook
     model = Webhook
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Webhook 4',
             'name': 'Webhook 4',
@@ -71,7 +71,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
 
 
 class EventRuleTest(APIViewTestCases.APIViewTestCase):
 class EventRuleTest(APIViewTestCases.APIViewTestCase):
     model = EventRule
     model = EventRule
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'enabled': False,
         'enabled': False,
         'description': 'New description',
         'description': 'New description',
@@ -149,7 +149,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
 
 
 class CustomFieldTest(APIViewTestCases.APIViewTestCase):
 class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     model = CustomField
     model = CustomField
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'content_types': ['dcim.site'],
             'content_types': ['dcim.site'],
@@ -201,7 +201,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
 
 
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
     model = CustomFieldChoiceSet
     model = CustomFieldChoiceSet
-    brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
+    brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Choice Set 4',
             'name': 'Choice Set 4',
@@ -330,7 +330,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
 
 class SavedFilterTest(APIViewTestCases.APIViewTestCase):
 class SavedFilterTest(APIViewTestCases.APIViewTestCase):
     model = SavedFilter
     model = SavedFilter
-    brief_fields = ['display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'content_types': ['dcim.site'],
             'content_types': ['dcim.site'],
@@ -455,7 +455,7 @@ class BookmarkTest(
 
 
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'content_types': ['dcim.device'],
             'content_types': ['dcim.device'],
@@ -500,7 +500,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class TagTest(APIViewTestCases.APIViewTestCase):
 class TagTest(APIViewTestCases.APIViewTestCase):
     model = Tag
     model = Tag
-    brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Tag 4',
             'name': 'Tag 4',
@@ -627,7 +627,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
 
 
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     model = ConfigContext
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Config Context 4',
             'name': 'Config Context 4',
@@ -708,7 +708,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
 
 
 class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
 class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConfigTemplate
     model = ConfigTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Config Template 4',
             'name': 'Config Template 4',
@@ -748,7 +748,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class ScriptTest(APITestCase):
 class ScriptTest(APITestCase):
 
 
-    class TestScript(Script):
+    class TestScriptClass(PythonClass):
 
 
         class Meta:
         class Meta:
             name = "Test script"
             name = "Test script"
@@ -767,27 +767,36 @@ class ScriptTest(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        ScriptModule.objects.create(
+        module = ScriptModule.objects.create(
             file_root=ManagedFileRootPathChoices.SCRIPTS,
             file_root=ManagedFileRootPathChoices.SCRIPTS,
             file_path='/var/tmp/script.py'
             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):
     def setUp(self):
         super().setUp()
         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
         from extras.api.views import ScriptViewSet
-        ScriptViewSet._get_script = self.get_test_script
+        Script.python_class = self.python_class
 
 
     def test_get_script(self):
     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)
         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']['var1'], 'StringVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         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/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
     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/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
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_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 taggit.managers import _TaggableManager
 
 
-from netbox.registry import registry
-
 
 
 def is_taggable(obj):
 def is_taggable(obj):
     """
     """
@@ -29,24 +27,6 @@ def image_upload(instance, filename):
     return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, 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):
 def is_script(obj):
     """
     """
     Returns True if the object is a Script or Report.
     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 import validators
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -149,3 +151,21 @@ class CustomValidator:
         if field is not None:
         if field is not None:
             raise ValidationError({field: message})
             raise ValidationError({field: message})
         raise ValidationError(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)
                 widget = widget_class(**data)
                 request.user.dashboard.add_widget(widget)
                 request.user.dashboard.add_widget(widget)
                 request.user.dashboard.save()
                 request.user.dashboard.save()
-                messages.success(request, f'Added widget {widget.id}')
+                messages.success(request, _('Added widget: ') + str(widget.id))
 
 
                 return HttpResponse(headers={
                 return HttpResponse(headers={
                     'HX-Redirect': reverse('home'),
                     'HX-Redirect': reverse('home'),
@@ -961,7 +961,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
             data['config'] = config_form.cleaned_data
             data['config'] = config_form.cleaned_data
             request.user.dashboard.config[str(id)].update(data)
             request.user.dashboard.config[str(id)].update(data)
             request.user.dashboard.save()
             request.user.dashboard.save()
-            messages.success(request, f'Updated widget {widget.id}')
+            messages.success(request, _('Updated widget: ') + str(widget.id))
 
 
             return HttpResponse(headers={
             return HttpResponse(headers={
                 'HX-Redirect': reverse('home'),
                 'HX-Redirect': reverse('home'),
@@ -997,9 +997,9 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
         if form.is_valid():
         if form.is_valid():
             request.user.dashboard.delete_widget(id)
             request.user.dashboard.delete_widget(id)
             request.user.dashboard.save()
             request.user.dashboard.save()
-            messages.success(request, f'Deleted widget {id}')
+            messages.success(request, _('Deleted widget: ') + str(id))
         else:
         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'))
         return redirect(reverse('home'))
 
 
@@ -1030,7 +1030,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request):
     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', {
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,
             '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', {
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script_class,
             'form': form,
             '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()
             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
         # Allow execution only if RQ worker process is running
         if not get_workers_for_queue('default'):
         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():
         elif form.is_valid():
             job = Job.enqueue(
             job = Job.enqueue(
                 run_script,
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script,
+                name=script_class.class_name,
                 user=request.user,
                 user=request.user,
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 interval=form.cleaned_data.pop('_interval'),
                 interval=form.cleaned_data.pop('_interval'),
                 data=form.cleaned_data,
                 data=form.cleaned_data,
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),
-                job_timeout=script.job_timeout,
+                job_timeout=script.python_class.job_timeout,
                 commit=form.cleaned_data.pop('_commit')
                 commit=form.cleaned_data.pop('_commit')
             )
             )
 
 
             return redirect('extras:script_result', job_pk=job.pk)
             return redirect('extras:script_result', job_pk=job.pk)
 
 
         return render(request, 'extras/script.html', {
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script.python_class(),
             'form': form,
             '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', {
         return render(request, 'extras/script/source.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script.python_class(),
+            'job_count': script.jobs.count(),
             'tab': 'source',
             '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(
         jobs_table = JobTable(
-            data=jobs,
+            data=script.jobs.all(),
             orderable=False,
             orderable=False,
             user=request.user
             user=request.user
         )
         )
         jobs_table.configure(request)
         jobs_table.configure(request)
 
 
         return render(request, 'extras/script/jobs.html', {
         return render(request, 'extras/script/jobs.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
             'table': jobs_table,
             'table': jobs_table,
+            'job_count': script.jobs.count(),
             'tab': 'jobs',
             '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):
     def get_required_permission(self):
         return 'extras.view_script'
         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 = {
         context = {
-            'script': script,
+            'script': job.object,
             'job': job,
             'job': job,
         }
         }
         if job.data and 'log' in job.data:
         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',
             'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'asn_count',
             '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',
             'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'site_count', 'provider_count',
             'created', 'last_updated', 'site_count', 'provider_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'asn', 'description')
 
 
 
 
 class AvailableASNSerializer(serializers.Serializer):
 class AvailableASNSerializer(serializers.Serializer):
@@ -104,6 +106,7 @@ class VRFSerializer(NetBoxModelSerializer):
             'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
             'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
             'prefix_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',
             'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
             'last_updated',
             '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',
             'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'aggregate_count',
             'last_updated', 'aggregate_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
 
 
 
 
 class AggregateSerializer(NetBoxModelSerializer):
 class AggregateSerializer(NetBoxModelSerializer):
@@ -153,6 +158,7 @@ class AggregateSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
             'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated',
             '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',
             'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
             'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
 
 
 
 
 class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
 class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
@@ -185,6 +192,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
             'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
             'last_updated',
             'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_interface(self, obj):
     def get_interface(self, obj):
@@ -212,6 +220,7 @@ class RoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
             'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'prefix_count', 'vlan_count',
             'last_updated', 'prefix_count', 'vlan_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
 
 
 
 
 class VLANGroupSerializer(NetBoxModelSerializer):
 class VLANGroupSerializer(NetBoxModelSerializer):
@@ -237,6 +246,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
             'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
         validators = []
         validators = []
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @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',
             'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
             'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
             'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
 
 
 
 
 class AvailableVLANSerializer(serializers.Serializer):
 class AvailableVLANSerializer(serializers.Serializer):
@@ -327,6 +338,7 @@ class PrefixSerializer(NetBoxModelSerializer):
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
             '_depth',
             '_depth',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
 
 
 
 
 class PrefixLengthSerializer(serializers.Serializer):
 class PrefixLengthSerializer(serializers.Serializer):
@@ -397,6 +409,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'mark_utilized', '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',
             'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated',
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_assigned_object(self, obj):
     def get_assigned_object(self, obj):
@@ -469,9 +483,10 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = ServiceTemplate
         model = ServiceTemplate
         fields = [
         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',
             'created', 'last_updated',
         ]
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
 
 
 
 
 class ServiceSerializer(NetBoxModelSerializer):
 class ServiceSerializer(NetBoxModelSerializer):
@@ -489,6 +504,7 @@ class ServiceSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Service
         model = Service
         fields = [
         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',
             '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.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db import transaction
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
+from django.utils.translation import gettext as _
 from django_pglocks import advisory_lock
 from django_pglocks import advisory_lock
 from drf_spectacular.utils import extend_schema
 from drf_spectacular.utils import extend_schema
 from netaddr import IPSet
 from netaddr import IPSet
@@ -354,7 +355,7 @@ class AvailablePrefixesView(AvailableObjectsView):
                     'vrf': parent.vrf.pk if parent.vrf else None,
                     'vrf': parent.vrf.pk if parent.vrf else None,
                 })
                 })
             else:
             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
         return requested_objects
 
 

+ 4 - 0
netbox/ipam/apps.py

@@ -6,4 +6,8 @@ class IPAMConfig(AppConfig):
     verbose_name = "IPAM"
     verbose_name = "IPAM"
 
 
     def ready(self):
     def ready(self):
+        from netbox.models.features import register_models
         from . import signals, search
         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.exceptions import ValidationError
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.db import models
 from django.db import models
+from django.utils.translation import gettext as _
 from netaddr import AddrFormatError, IPNetwork
 from netaddr import AddrFormatError, IPNetwork
 
 
 from . import lookups, validators
 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.)
             # Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
             return IPNetwork(value)
             return IPNetwork(value)
         except AddrFormatError:
         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:
         except (TypeError, ValueError) as e:
             raise ValidationError(e)
             raise ValidationError(e)
 
 

+ 8 - 7
netbox/ipam/formfields.py

@@ -1,6 +1,7 @@
 from django import forms
 from django import forms
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import validate_ipv4_address, validate_ipv6_address
 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
 from netaddr import IPAddress, IPNetwork, AddrFormatError
 
 
 
 
@@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
 
 
 class IPAddressFormField(forms.Field):
 class IPAddressFormField(forms.Field):
     default_error_messages = {
     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):
     def to_python(self, value):
@@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
             try:
             try:
                 validate_ipv6_address(value)
                 validate_ipv6_address(value)
             except ValidationError:
             except ValidationError:
-                raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
+                raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
 
 
         try:
         try:
             return IPAddress(value)
             return IPAddress(value)
         except ValueError:
         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:
         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):
 class IPNetworkFormField(forms.Field):
     default_error_messages = {
     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):
     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.
         # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
         if len(value.split('/')) != 2:
         if len(value.split('/')) != 2:
-            raise ValidationError('CIDR mask (e.g. /24) is required.')
+            raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
 
 
         try:
         try:
             return IPNetwork(value)
             return IPNetwork(value)
         except AddrFormatError:
         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']:
             if not self.cleaned_data['description']:
                 self.cleaned_data['description'] = service_template.description
                 self.cleaned_data['description'] = service_template.description
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
         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):
 class ASNRangeTest(APIViewTestCases.APIViewTestCase):
     model = ASNRange
     model = ASNRange
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -135,7 +135,7 @@ class ASNRangeTest(APIViewTestCases.APIViewTestCase):
 
 
 class ASNTest(APIViewTestCases.APIViewTestCase):
 class ASNTest(APIViewTestCases.APIViewTestCase):
     model = ASN
     model = ASN
-    brief_fields = ['asn', 'display', 'id', 'url']
+    brief_fields = ['asn', 'description', 'display', 'id', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -191,7 +191,7 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
 
 
 class VRFTest(APIViewTestCases.APIViewTestCase):
 class VRFTest(APIViewTestCases.APIViewTestCase):
     model = VRF
     model = VRF
-    brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'rd', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'VRF 4',
             'name': 'VRF 4',
@@ -223,7 +223,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
 
 
 class RouteTargetTest(APIViewTestCases.APIViewTestCase):
 class RouteTargetTest(APIViewTestCases.APIViewTestCase):
     model = RouteTarget
     model = RouteTarget
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': '65000:1004',
             'name': '65000:1004',
@@ -252,7 +252,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase):
 
 
 class RIRTest(APIViewTestCases.APIViewTestCase):
 class RIRTest(APIViewTestCases.APIViewTestCase):
     model = RIR
     model = RIR
-    brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['aggregate_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'RIR 4',
             'name': 'RIR 4',
@@ -284,7 +284,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase):
 
 
 class AggregateTest(APIViewTestCases.APIViewTestCase):
 class AggregateTest(APIViewTestCases.APIViewTestCase):
     model = Aggregate
     model = Aggregate
-    brief_fields = ['display', 'family', 'id', 'prefix', 'url']
+    brief_fields = ['description', 'display', 'family', 'id', 'prefix', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -323,7 +323,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
 
 
 class RoleTest(APIViewTestCases.APIViewTestCase):
 class RoleTest(APIViewTestCases.APIViewTestCase):
     model = Role
     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 = [
     create_data = [
         {
         {
             'name': 'Role 4',
             'name': 'Role 4',
@@ -355,7 +355,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
 
 
 class PrefixTest(APIViewTestCases.APIViewTestCase):
 class PrefixTest(APIViewTestCases.APIViewTestCase):
     model = Prefix
     model = Prefix
-    brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
     create_data = [
     create_data = [
         {
         {
             'prefix': '192.168.4.0/24',
             'prefix': '192.168.4.0/24',
@@ -534,7 +534,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
 
 
 class IPRangeTest(APIViewTestCases.APIViewTestCase):
 class IPRangeTest(APIViewTestCases.APIViewTestCase):
     model = IPRange
     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 = [
     create_data = [
         {
         {
             'start_address': '192.168.4.10/24',
             'start_address': '192.168.4.10/24',
@@ -633,7 +633,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
 
 
 class IPAddressTest(APIViewTestCases.APIViewTestCase):
 class IPAddressTest(APIViewTestCases.APIViewTestCase):
     model = IPAddress
     model = IPAddress
-    brief_fields = ['address', 'display', 'family', 'id', 'url']
+    brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
     create_data = [
     create_data = [
         {
         {
             'address': '192.168.0.4/24',
             'address': '192.168.0.4/24',
@@ -718,7 +718,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
 
 
 class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
 class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
     model = FHRPGroup
     model = FHRPGroup
-    brief_fields = ['display', 'group_id', 'id', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'group_id', 'id', 'protocol', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
         'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
         'group_id': 200,
         'group_id': 200,
@@ -839,7 +839,7 @@ class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
 
 
 class VLANGroupTest(APIViewTestCases.APIViewTestCase):
 class VLANGroupTest(APIViewTestCases.APIViewTestCase):
     model = VLANGroup
     model = VLANGroup
-    brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count']
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'vlan_count']
     create_data = [
     create_data = [
         {
         {
             'name': 'VLAN Group 4',
             'name': 'VLAN Group 4',
@@ -960,7 +960,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
 
 
 class VLANTest(APIViewTestCases.APIViewTestCase):
 class VLANTest(APIViewTestCases.APIViewTestCase):
     model = VLAN
     model = VLAN
-    brief_fields = ['display', 'id', 'name', 'url', 'vid']
+    brief_fields = ['description', 'display', 'id', 'name', 'url', 'vid']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1020,7 +1020,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
 
 
 class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
 class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ServiceTemplate
     model = ServiceTemplate
-    brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1055,7 +1055,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class ServiceTest(APIViewTestCases.APIViewTestCase):
 class ServiceTest(APIViewTestCases.APIViewTestCase):
     model = Service
     model = Service
-    brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }

+ 9 - 4
netbox/ipam/validators.py

@@ -1,14 +1,19 @@
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import BaseValidator, RegexValidator
 from django.core.validators import BaseValidator, RegexValidator
+from django.utils.translation import gettext_lazy as _
 
 
 
 
 def prefix_validator(prefix):
 def prefix_validator(prefix):
     if prefix.ip != prefix.cidr.ip:
     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):
 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'
     code = 'max_prefix_length'
 
 
     def compare(self, a, b):
     def compare(self, a, b):
@@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
 
 
 
 
 class MinPrefixLengthValidator(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'
     code = 'min_prefix_length'
 
 
     def compare(self, a, b):
     def compare(self, a, b):
@@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
 
 
 DNSValidator = RegexValidator(
 DNSValidator = RegexValidator(
     regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
     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'
     code='invalid'
 )
 )

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

@@ -1,4 +1,5 @@
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from netaddr import IPNetwork
 from netaddr import IPNetwork
@@ -59,11 +60,13 @@ class ChoiceField(serializers.Field):
         if data == '':
         if data == '':
             if self.allow_blank:
             if self.allow_blank:
                 return data
                 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
         # Provide an explicit error message if the request is trying to write a dict or list
         if isinstance(data, (dict, 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
         # Check for string representations of boolean/integer values
         if hasattr(data, 'lower'):
         if hasattr(data, 'lower'):
@@ -83,7 +86,7 @@ class ChoiceField(serializers.Field):
         except TypeError:  # Input is an unhashable type
         except TypeError:  # Input is an unhashable type
             pass
             pass
 
 
-        raise ValidationError(f"{data} is not a valid choice.")
+        raise ValidationError(_("{value} is not a valid choice.").format(value=data))
 
 
     @property
     @property
     def choices(self):
     def choices(self):
@@ -96,8 +99,8 @@ class ContentTypeField(RelatedField):
     Represent a ContentType as '<app_label>.<model>'
     Represent a ContentType as '<app_label>.<model>'
     """
     """
     default_error_messages = {
     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):
     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.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
+from django.utils.translation import gettext_lazy as _
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 from rest_framework.exceptions import ValidationError
 
 
@@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
             try:
             try:
                 return queryset.get(**params)
                 return queryset.get(**params)
             except ObjectDoesNotExist:
             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:
             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:
             except FieldError as e:
                 raise ValidationError(e)
                 raise ValidationError(e)
 
 
@@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
             pk = int(data)
             pk = int(data)
         except (TypeError, ValueError):
         except (TypeError, ValueError):
             raise ValidationError(
             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
         # Look up object by PK
         try:
         try:
             return self.Meta.model.objects.get(pk=pk)
             return self.Meta.model.objects.get(pk=pk)
         except ObjectDoesNotExist:
         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
 # 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.
     Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
     """
     """
+    brief = False
+
     def initial(self, request, *args, **kwargs):
     def initial(self, request, *args, **kwargs):
         super().initial(request, *args, **kwargs)
         super().initial(request, *args, **kwargs)
 
 
@@ -42,6 +44,13 @@ class BaseViewSet(GenericViewSet):
             if action := HTTP_ACTIONS[request.method]:
             if action := HTTP_ACTIONS[request.method]:
                 self.queryset = self.queryset.restrict(request.user, action)
                 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):
     def get_queryset(self):
         qs = super().get_queryset()
         qs = super().get_queryset()
         serializer_class = self.get_serializer_class()
         serializer_class = self.get_serializer_class()
@@ -66,12 +75,17 @@ class BaseViewSet(GenericViewSet):
 
 
     @cached_property
     @cached_property
     def requested_fields(self):
     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(
 class NetBoxReadOnlyModelViewSet(
-    mixins.BriefModeMixin,
     mixins.CustomFieldsMixin,
     mixins.CustomFieldsMixin,
     mixins.ExportTemplatesMixin,
     mixins.ExportTemplatesMixin,
     drf_mixins.RetrieveModelMixin,
     drf_mixins.RetrieveModelMixin,
@@ -85,7 +99,6 @@ class NetBoxModelViewSet(
     mixins.BulkUpdateModelMixin,
     mixins.BulkUpdateModelMixin,
     mixins.BulkDestroyModelMixin,
     mixins.BulkDestroyModelMixin,
     mixins.ObjectValidationMixin,
     mixins.ObjectValidationMixin,
-    mixins.BriefModeMixin,
     mixins.CustomFieldsMixin,
     mixins.CustomFieldsMixin,
     mixins.ExportTemplatesMixin,
     mixins.ExportTemplatesMixin,
     drf_mixins.CreateModelMixin,
     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.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db import transaction
@@ -8,13 +6,9 @@ from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
-from netbox.api.exceptions import SerializerNotFound
 from netbox.api.serializers import BulkOperationSerializer
 from netbox.api.serializers import BulkOperationSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from utilities.api import get_serializer_for_model
 
 
 __all__ = (
 __all__ = (
-    'BriefModeMixin',
     'BulkDestroyModelMixin',
     'BulkDestroyModelMixin',
     'BulkUpdateModelMixin',
     'BulkUpdateModelMixin',
     'CustomFieldsMixin',
     '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:
 class CustomFieldsMixin:
     """
     """
     For models which support custom fields, populate the `custom_fields` context.
     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.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
 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.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
 
 
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.constants import CONSTRAINT_TOKEN_USER
-from users.models import ObjectPermission
+from users.models import Group, ObjectPermission
 from utilities.permissions import (
 from utilities.permissions import (
     permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
     permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
 )
 )
@@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
     'hubspot': ('HubSpot', 'hubspot'),
     'hubspot': ('HubSpot', 'hubspot'),
     'keycloak': ('Keycloak', None),
     'keycloak': ('Keycloak', None),
     'microsoft-graph': ('Microsoft Graph', 'microsoft'),
     'microsoft-graph': ('Microsoft Graph', 'microsoft'),
+    'oidc': ('OpenID Connect', None),
     'okta': ('Okta', None),
     'okta': ('Okta', None),
     'okta-openidconnect': ('Okta (OIDC)', None),
     'okta-openidconnect': ('Okta (OIDC)', None),
     'salesforce-oauth2': ('Salesforce', 'salesforce'),
     'salesforce-oauth2': ('Salesforce', 'salesforce'),
@@ -132,7 +134,9 @@ class ObjectPermissionMixin:
         # Sanity check: Ensure that the requested permission applies to the specified object
         # Sanity check: Ensure that the requested permission applies to the specified object
         model = obj._meta.concrete_model
         model = obj._meta.concrete_model
         if model._meta.label_lower != '.'.join((app_label, model_name)):
         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
         # Compile a QuerySet filter that matches all instances of the specified model
         tokens = {
         tokens = {

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

@@ -4,6 +4,7 @@ import threading
 from django.conf import settings
 from django.conf import settings
 from django.core.cache import cache
 from django.core.cache import cache
 from django.db.utils import DatabaseError
 from django.db.utils import DatabaseError
+from django.utils.translation import gettext_lazy as _
 
 
 from .parameters import PARAMS
 from .parameters import PARAMS
 
 
@@ -63,7 +64,7 @@ class Config:
         if item in self.defaults:
         if item in self.defaults:
             return self.defaults[item]
             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):
     def _populate_from_cache(self):
         """Populate config data from Redis cache"""
         """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.
         Return the ContentType of the form's model.
         """
         """
         if not getattr(self, 'model', None):
         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)
         return ContentType.objects.get_for_model(self.model)
 
 
     def _get_custom_fields(self, content_type):
     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.contrib.contenttypes.fields import GenericRelation
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 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 import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
@@ -14,7 +12,7 @@ from taggit.managers import TaggableManager
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.models import ContentType
 from core.models import ContentType
 from extras.choices import *
 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.config import get_config
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.signals import post_clean
 from netbox.signals import post_clean
@@ -37,6 +35,7 @@ __all__ = (
     'JournalingMixin',
     'JournalingMixin',
     'SyncedDataMixin',
     'SyncedDataMixin',
     'TagsMixin',
     'TagsMixin',
+    'register_models',
 )
 )
 
 
 
 
@@ -275,16 +274,20 @@ class CustomFieldsMixin(models.Model):
         # Validate all field values
         # Validate all field values
         for field_name, value in self.custom_field_data.items():
         for field_name, value in self.custom_field_data.items():
             if field_name not in custom_fields:
             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:
             try:
                 custom_fields[field_name].validate(value)
                 custom_fields[field_name].validate(value)
             except ValidationError as e:
             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
         # Check for missing required values
         for cf in custom_fields.values():
         for cf in custom_fields.values():
             if cf.required and cf.name not in self.custom_field_data:
             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):
 class CustomLinksMixin(models.Model):
@@ -489,10 +492,10 @@ class SyncedDataMixin(models.Model):
         # Create/delete AutoSyncRecord as needed
         # Create/delete AutoSyncRecord as needed
         content_type = ContentType.objects.get_for_model(self)
         content_type = ContentType.objects.get_for_model(self)
         if self.auto_sync_enabled:
         if self.auto_sync_enabled:
-            AutoSyncRecord.objects.get_or_create(
-                datafile=self.data_file,
+            AutoSyncRecord.objects.update_or_create(
                 object_type=content_type,
                 object_type=content_type,
-                object_id=self.pk
+                object_id=self.pk,
+                defaults={'datafile': self.data_file}
             )
             )
         else:
         else:
             AutoSyncRecord.objects.filter(
             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
         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.
         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
                 # Proxy model for auth.Group
                 MenuItem(
                 MenuItem(
-                    link=f'users:netboxgroup_list',
+                    link=f'users:group_list',
                     link_text=_('Groups'),
                     link_text=_('Groups'),
                     permissions=[f'auth.view_group'],
                     permissions=[f'auth.view_group'],
                     staff_only=True,
                     staff_only=True,
                     buttons=(
                     buttons=(
                         MenuItemButton(
                         MenuItemButton(
-                            link=f'users:netboxgroup_add',
+                            link=f'users:group_add',
                             title='Add',
                             title='Add',
                             icon_class='mdi mdi-plus-thick',
                             icon_class='mdi mdi-plus-thick',
                             permissions=[f'auth.add_group']
                             permissions=[f'auth.add_group']
                         ),
                         ),
                         MenuItemButton(
                         MenuItemButton(
-                            link=f'users:netboxgroup_import',
+                            link=f'users:group_import',
                             title='Import',
                             title='Import',
                             icon_class='mdi mdi-upload',
                             icon_class='mdi mdi-upload',
                             permissions=[f'auth.add_group']
                             permissions=[f'auth.add_group']

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

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

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

@@ -1,6 +1,7 @@
 from netbox.navigation import MenuGroup
 from netbox.navigation import MenuGroup
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
 from django.utils.text import slugify
 from django.utils.text import slugify
+from django.utils.translation import gettext as _
 
 
 __all__ = (
 __all__ = (
     'PluginMenu',
     'PluginMenu',
@@ -42,11 +43,11 @@ class PluginMenuItem:
         self.staff_only = staff_only
         self.staff_only = staff_only
         if permissions is not None:
         if permissions is not None:
             if type(permissions) not in (list, tuple):
             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
             self.permissions = permissions
         if buttons is not None:
         if buttons is not None:
             if type(buttons) not in (list, tuple):
             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
             self.buttons = buttons
 
 
 
 
@@ -64,9 +65,9 @@ class PluginMenuButton:
         self.icon_class = icon_class
         self.icon_class = icon_class
         if permissions is not None:
         if permissions is not None:
             if type(permissions) not in (list, tuple):
             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
             self.permissions = permissions
         if color is not None:
         if color is not None:
             if color not in ButtonColorChoices.values():
             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
             self.color = color

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

@@ -1,5 +1,6 @@
 import inspect
 import inspect
 
 
+from django.utils.translation import gettext_lazy as _
 from netbox.registry import registry
 from netbox.registry import registry
 from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
 from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
 from .templates import PluginTemplateExtension
 from .templates import PluginTemplateExtension
@@ -20,18 +21,32 @@ def register_template_extensions(class_list):
     # Validation
     # Validation
     for template_extension in class_list:
     for template_extension in class_list:
         if not inspect.isclass(template_extension):
         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):
         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:
         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)
         registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
 
 
 
 
 def register_menu(menu):
 def register_menu(menu):
     if not isinstance(menu, PluginMenu):
     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)
     registry['plugins']['menus'].append(menu)
 
 
 
 
@@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
     # Validation
     # Validation
     for menu_link in class_list:
     for menu_link in class_list:
         if not isinstance(menu_link, PluginMenuItem):
         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:
         for button in menu_link.buttons:
             if not isinstance(button, PluginMenuButton):
             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
     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.template.loader import get_template
+from django.utils.translation import gettext as _
 
 
 __all__ = (
 __all__ = (
     'PluginTemplateExtension',
     'PluginTemplateExtension',
@@ -31,7 +32,7 @@ class PluginTemplateExtension:
         if extra_context is None:
         if extra_context is None:
             extra_context = {}
             extra_context = {}
         elif not isinstance(extra_context, dict):
         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})
         return get_template(template_name).render({**self.context, **extra_context})
 
 

+ 4 - 3
netbox/netbox/registry.py

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

+ 2 - 2
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ from netbox.plugins import PluginConfig
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.7.3-dev'
+VERSION = '4.0.0-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 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('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 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_uid',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.user.get_username',
     'social_core.pipeline.user.get_username',
-    'social_core.pipeline.social_auth.associate_by_email',
     'social_core.pipeline.user.create_user',
     'social_core.pipeline.user.create_user',
     'social_core.pipeline.social_auth.associate_user',
     'social_core.pipeline.social_auth.associate_user',
     'netbox.authentication.user_default_groups_handler',
     '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.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import Client
 from django.test import Client
 from django.test.utils import override_settings
 from django.test.utils import override_settings
@@ -12,7 +11,7 @@ from rest_framework.test import APIClient
 
 
 from dcim.models import Site
 from dcim.models import Site
 from ipam.models import Prefix
 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 import TestCase
 from utilities.testing.api import APITestCase
 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)
         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):
     def test_models(self):
         from netbox.tests.dummy_plugin.models import DummyModel
         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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 from django_tables2.export import TableExport
 from django_tables2.export import TableExport
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
@@ -319,7 +320,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             if type(field.widget) is not HiddenInput
             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
         # Save the primary object
         obj = self.save_object(model_form, request)
         obj = self.save_object(model_form, request)
@@ -344,11 +345,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     related_obj = f.save()
                     related_obj = f.save()
                     related_obj_pks.append(related_obj.pk)
                     related_obj_pks.append(related_obj.pk)
                 else:
                 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 subfield_name, errors in f.errors.items():
                         for err in errors:
                         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()
                     raise AbortTransaction()
 
 
             # Enforce object-level permissions on related objects
             # Enforce object-level permissions on related objects
@@ -389,7 +393,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 try:
                 try:
                     instance = prefetched_objects[object_id]
                     instance = prefetched_objects[object_id]
                 except KeyError:
                 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('')
                     raise ValidationError('')
 
 
                 # Take a snapshot for change logging
                 # Take a snapshot for change logging
@@ -415,7 +419,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             restrict_form_fields(model_form, request.user)
             restrict_form_fields(model_form, request.user)
 
 
             if model_form.is_valid():
             if model_form.is_valid():
-                obj = self._save_object(model_form, request)
+                obj = self._save_object(form, model_form, request)
                 saved_objects.append(obj)
                 saved_objects.append(obj)
             else:
             else:
                 # Replicate model form errors for display
                 # 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.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 
 
 from extras.signals import clear_events
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
@@ -100,7 +101,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
             request: The current request
             request: The current request
             parent: The parent object
             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):
     def prep_table_data(self, request, queryset, parent):
         """
         """

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 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');
   const value = link.getAttribute('data-value');
 
 
   //@ts-ignore
   //@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
 // Initialize <select> elements which are populated via a REST API call
 export function initDynamicSelects(): void {
 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, {
     new DynamicTomSelect(select, {
       ...config,
       ...config,
       valueField: VALUE_FIELD,
       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
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
   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, {
     new TomSelect(select, {
       ...config,
       ...config,
+      maxOptions: undefined,
     });
     });
   }
   }
 }
 }
@@ -23,9 +24,10 @@ export function initColorSelects(): void {
     )}"></span> ${escape(item.text)}</div>`;
     )}"></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, {
     new TomSelect(select, {
       ...config,
       ...config,
+      maxOptions: undefined,
       render: {
       render: {
         option: renderColor,
         option: renderColor,
         item: renderColor,
         item: renderColor,

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

@@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
   return selected;
   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.
  * Toggle visibility of an element.
  */
  */

Некоторые файлы не были показаны из-за большого количества измененных файлов