Bladeren bron

Merge branch 'main' into feature

Jeremy Stretch 6 maanden geleden
bovenliggende
commit
a7247f8815
100 gewijzigde bestanden met toevoegingen van 3793 en 2780 verwijderingen
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 3 0
      .github/codeql/codeql-config.yml
  4. 42 0
      .github/workflows/codeql.yml
  5. 9 0
      contrib/generated_schema.json
  6. 32 0
      docs/release-notes/version-4.3.md
  7. 12 0
      netbox/core/signals.py
  8. 4 0
      netbox/dcim/choices.py
  9. 6 6
      netbox/dcim/filtersets.py
  10. 2 0
      netbox/dcim/management/commands/buildschema.py
  11. 287 0
      netbox/dcim/migrations/0209_device_component_denorm_site_location.py
  12. 19 0
      netbox/dcim/migrations/0210_macaddress_ordering.py
  13. 1 1
      netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py
  14. 1 1
      netbox/dcim/migrations/0212_interface_tx_power_negative.py
  15. 1 1
      netbox/dcim/migrations/0213_platform_parent.py
  16. 1 1
      netbox/dcim/migrations/0214_platform_rebuild.py
  17. 1 1
      netbox/dcim/migrations/0215_rackreservation_status.py
  18. 10 0
      netbox/dcim/models/cables.py
  19. 31 0
      netbox/dcim/models/device_components.py
  20. 7 3
      netbox/dcim/models/devices.py
  21. 31 2
      netbox/dcim/signals.py
  22. 5 1
      netbox/dcim/tables/cables.py
  23. 188 17
      netbox/dcim/tests/test_filtersets.py
  24. 8 1
      netbox/extras/dashboard/widgets.py
  25. 3 0
      netbox/extras/models/models.py
  26. 18 0
      netbox/extras/tests/test_views.py
  27. 10 3
      netbox/extras/views.py
  28. 14 6
      netbox/ipam/forms/model_forms.py
  29. 23 0
      netbox/ipam/graphql/filters.py
  30. 20 0
      netbox/ipam/migrations/0082_add_prefix_network_containment_indexes.py
  31. 8 0
      netbox/ipam/models/ip.py
  32. 5 1
      netbox/netbox/tables/columns.py
  33. 0 0
      netbox/project-static/dist/netbox.css
  34. 0 0
      netbox/project-static/dist/netbox.js
  35. 0 0
      netbox/project-static/dist/netbox.js.map
  36. 38 11
      netbox/project-static/src/buttons/moveOptions.ts
  37. 3 1
      netbox/project-static/src/search.ts
  38. 2 0
      netbox/project-static/styles/custom/_code.scss
  39. 65 65
      netbox/project-static/yarn.lock
  40. 2 2
      netbox/release.yaml
  41. 5 5
      netbox/templates/circuits/inc/circuit_termination.html
  42. 3 3
      netbox/templates/circuits/inc/circuit_termination_fields.html
  43. 1 1
      netbox/templates/dcim/frontport.html
  44. 3 3
      netbox/templates/dcim/inc/cable_termination.html
  45. 1 1
      netbox/templates/dcim/inc/connection_endpoints.html
  46. 2 2
      netbox/templates/dcim/inc/panels/inventory_items.html
  47. 1 1
      netbox/templates/dcim/interface.html
  48. 1 1
      netbox/templates/dcim/rearport.html
  49. 1 1
      netbox/templates/extras/htmx/script_result.html
  50. 139 0
      netbox/templates/extras/inc/script_list_content.html
  51. 3 3
      netbox/templates/extras/object_render_config.html
  52. 4 0
      netbox/templates/extras/schema/devicetype_schema.jinja2
  53. 1 131
      netbox/templates/extras/script_list.html
  54. 2 2
      netbox/templates/extras/tableconfig_edit.html
  55. 17 0
      netbox/templates/graphql/graphiql.html
  56. 2 2
      netbox/templates/htmx/notifications.html
  57. 2 2
      netbox/templates/inc/light_toggle.html
  58. 2 1
      netbox/templates/inc/notification_bell.html
  59. 2 2
      netbox/templates/inc/user_menu.html
  60. 2 2
      netbox/templates/ipam/inc/panels/fhrp_groups.html
  61. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  62. 166 164
      netbox/translations/cs/LC_MESSAGES/django.po
  63. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  64. 165 164
      netbox/translations/da/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  66. 165 164
      netbox/translations/de/LC_MESSAGES/django.po
  67. 169 167
      netbox/translations/en/LC_MESSAGES/django.po
  68. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  69. 165 164
      netbox/translations/es/LC_MESSAGES/django.po
  70. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  71. 169 168
      netbox/translations/fr/LC_MESSAGES/django.po
  72. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  73. 165 164
      netbox/translations/it/LC_MESSAGES/django.po
  74. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  75. 168 167
      netbox/translations/ja/LC_MESSAGES/django.po
  76. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  77. 165 164
      netbox/translations/nl/LC_MESSAGES/django.po
  78. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  79. 165 164
      netbox/translations/pl/LC_MESSAGES/django.po
  80. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  81. 165 164
      netbox/translations/pt/LC_MESSAGES/django.po
  82. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  83. 165 164
      netbox/translations/ru/LC_MESSAGES/django.po
  84. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  85. 168 167
      netbox/translations/tr/LC_MESSAGES/django.po
  86. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  87. 165 164
      netbox/translations/uk/LC_MESSAGES/django.po
  88. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  89. 168 167
      netbox/translations/zh/LC_MESSAGES/django.po
  90. 12 3
      netbox/users/forms/model_forms.py
  91. 1 1
      netbox/users/tests/test_views.py
  92. 1 2
      netbox/utilities/api.py
  93. 11 3
      netbox/utilities/data.py
  94. 4 2
      netbox/utilities/forms/fields/array.py
  95. 77 0
      netbox/utilities/forms/widgets/select.py
  96. 34 0
      netbox/utilities/prefetch.py
  97. 4 4
      netbox/utilities/templates/helpers/table_config_form.html
  98. 31 0
      netbox/utilities/templates/widgets/splitmultiselect.html
  99. 3 0
      netbox/utilities/testing/api.py
  100. 14 0
      netbox/utilities/tests/test_data.py

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

@@ -15,7 +15,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.3.6
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -27,7 +27,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.3.6
     validations:
       required: true
   - type: dropdown

+ 3 - 0
.github/codeql/codeql-config.yml

@@ -0,0 +1,3 @@
+paths-ignore:
+  # Ignore compiled JS
+  - netbox/project-static/dist

+ 42 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,42 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ "main", "feature" ]
+  pull_request:
+    branches: [ "main", "feature" ]
+  schedule:
+    - cron: '38 16 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze (${{ matrix.language }})
+    runs-on: ubuntu-latest
+    permissions:
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+        - language: actions
+          build-mode: none
+        - language: javascript-typescript
+          build-mode: none
+        - language: python
+          build-mode: none
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v4
+
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v3
+      with:
+        languages: ${{ matrix.language }}
+        build-mode: ${{ matrix.build-mode }}
+        config-file: .github/codeql/codeql-config.yml
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v3
+      with:
+        category: "/language:${{matrix.language}}"

+ 9 - 0
contrib/generated_schema.json

@@ -95,6 +95,7 @@
                         "iec-60320-c8",
                         "iec-60320-c14",
                         "iec-60320-c16",
+                        "iec-60320-c18",
                         "iec-60320-c20",
                         "iec-60320-c22",
                         "iec-60309-p-n-e-4h",
@@ -209,6 +210,7 @@
                         "iec-60320-c7",
                         "iec-60320-c13",
                         "iec-60320-c15",
+                        "iec-60320-c17",
                         "iec-60320-c19",
                         "iec-60320-c21",
                         "iec-60309-p-n-e-4h",
@@ -474,6 +476,13 @@
                         "passive-48v-2pair",
                         "passive-48v-4pair"
                     ]
+                },
+                "rf_role": {
+                    "type": "string",
+                    "enum": [
+                        "ap",
+                        "station"
+                    ]
                 }
             }
         },

+ 32 - 0
docs/release-notes/version-4.3.md

@@ -1,5 +1,34 @@
 # NetBox v4.3
 
+## v4.3.6 (2025-08-12)
+
+### Enhancements
+
+* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
+* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
+* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
+* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
+* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
+* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
+* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
+
+### Bug Fixes
+
+* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
+* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
+* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
+* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
+* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
+* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
+* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
+* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
+* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
+* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
+* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
+* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
+* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
+* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
+
 ## v4.3.5 (2025-07-29)
 
 ### Enhancements
@@ -16,6 +45,9 @@
 * [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
 * [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
 
+!!! note "Plugin Developer Advisory"
+    The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
+
 ## v4.3.4 (2025-07-15)
 
 ### Enhancements

+ 12 - 0
netbox/core/signals.py

@@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.events import *
 from core.models import ObjectType
 from extras.events import enqueue_event
+from extras.models import Tag
 from extras.utils import run_validators
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
@@ -104,6 +105,17 @@ def handle_changed_object(sender, instance, **kwargs):
         # m2m_changed with objects added or removed
         m2m_changed = True
         event_type = OBJECT_UPDATED
+    elif kwargs.get('action') == 'post_clear':
+        # Handle clearing of an M2M field
+        if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
+            # Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
+            # prechange snapshot is empty)
+            m2m_changed = True
+            event_type = OBJECT_UPDATED
+        else:
+            # Other endpoints are unimpacted as they send post_add and post_remove
+            # This will impact changes that utilize clear() however so we may want to give consideration for this branch
+            return
     else:
         return
 

+ 4 - 0
netbox/dcim/choices.py

@@ -362,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_IEC_C8 = 'iec-60320-c8'
     TYPE_IEC_C14 = 'iec-60320-c14'
     TYPE_IEC_C16 = 'iec-60320-c16'
+    TYPE_IEC_C18 = 'iec-60320-c18'
     TYPE_IEC_C20 = 'iec-60320-c20'
     TYPE_IEC_C22 = 'iec-60320-c22'
     # IEC 60309
@@ -480,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_IEC_C8, 'C8'),
             (TYPE_IEC_C14, 'C14'),
             (TYPE_IEC_C16, 'C16'),
+            (TYPE_IEC_C18, 'C18'),
             (TYPE_IEC_C20, 'C20'),
             (TYPE_IEC_C22, 'C22'),
         )),
@@ -617,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_IEC_C7 = 'iec-60320-c7'
     TYPE_IEC_C13 = 'iec-60320-c13'
     TYPE_IEC_C15 = 'iec-60320-c15'
+    TYPE_IEC_C17 = 'iec-60320-c17'
     TYPE_IEC_C19 = 'iec-60320-c19'
     TYPE_IEC_C21 = 'iec-60320-c21'
     # IEC 60309
@@ -729,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_IEC_C7, 'C7'),
             (TYPE_IEC_C13, 'C13'),
             (TYPE_IEC_C15, 'C15'),
+            (TYPE_IEC_C17, 'C17'),
             (TYPE_IEC_C19, 'C19'),
             (TYPE_IEC_C21, 'C21'),
         )),

+ 6 - 6
netbox/dcim/filtersets.py

@@ -1548,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         label=_('Site group (slug)'),
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site',
+        field_name='_site',
         queryset=Site.objects.all(),
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label=_('Site name (slug)'),
     )
     location_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location',
+        field_name='_location',
         queryset=Location.objects.all(),
         label=_('Location (ID)'),
     )
     location = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location__slug',
+        field_name='_location__slug',
         queryset=Location.objects.all(),
         to_field_name='slug',
         label=_('Location (slug)'),
     )
     rack_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack',
+        field_name='_rack',
         queryset=Rack.objects.all(),
         label=_('Rack (ID)'),
     )
     rack = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack__name',
+        field_name='_rack__name',
         queryset=Rack.objects.all(),
         to_field_name='name',
         label=_('Rack (name)'),

+ 2 - 0
netbox/dcim/management/commands/buildschema.py

@@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
 
 from dcim.choices import *
 from netbox.choices import WeightUnitChoices
+from wireless.choices import WirelessRoleChoices
 
 TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
 OUTPUT_FILENAME = 'contrib/generated_schema.json'
@@ -23,6 +24,7 @@ CHOICES_MAP = {
     'interface_type_choices': InterfaceTypeChoices,
     'interface_poe_mode_choices': InterfacePoEModeChoices,
     'interface_poe_type_choices': InterfacePoETypeChoices,
+    'interface_rf_role_choices': WirelessRoleChoices,
     'front_port_type_choices': PortTypeChoices,
     'rear_port_type_choices': PortTypeChoices,
 }

+ 287 - 0
netbox/dcim/migrations/0209_device_component_denorm_site_location.py

@@ -0,0 +1,287 @@
+import django.db.models.deletion
+from django.db import migrations, models
+from django.db.models import OuterRef, Subquery
+
+
+def populate_denormalized_data(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    component_models = (
+        apps.get_model('dcim', 'ConsolePort'),
+        apps.get_model('dcim', 'ConsoleServerPort'),
+        apps.get_model('dcim', 'PowerPort'),
+        apps.get_model('dcim', 'PowerOutlet'),
+        apps.get_model('dcim', 'Interface'),
+        apps.get_model('dcim', 'FrontPort'),
+        apps.get_model('dcim', 'RearPort'),
+        apps.get_model('dcim', 'DeviceBay'),
+        apps.get_model('dcim', 'ModuleBay'),
+        apps.get_model('dcim', 'InventoryItem'),
+    )
+
+    for model in component_models:
+        subquery = Device.objects.filter(pk=OuterRef('device_id'))
+        model.objects.update(
+            _site=Subquery(subquery.values('site_id')[:1]),
+            _location=Subquery(subquery.values('location_id')[:1]),
+            _rack=Subquery(subquery.values('rack_id')[:1]),
+        )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0208_devicerole_uniqueness'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.RunPython(populate_denormalized_data),
+    ]

+ 19 - 0
netbox/dcim/migrations/0210_macaddress_ordering.py

@@ -0,0 +1,19 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0209_device_component_denorm_site_location'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='macaddress',
+            options={
+                'ordering': ('mac_address', 'pk'),
+                'verbose_name': 'MAC address',
+                'verbose_name_plural': 'MAC addresses'
+            },
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py → netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0208_devicerole_uniqueness'),
+        ('dcim', '0210_macaddress_ordering'),
         ('extras', '0129_fix_script_paths'),
     ]
 

+ 1 - 1
netbox/dcim/migrations/0210_interface_tx_power_negative.py → netbox/dcim/migrations/0212_interface_tx_power_negative.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0209_platform_manufacturer_uniqueness'),
+        ('dcim', '0211_platform_manufacturer_uniqueness'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0211_platform_parent.py → netbox/dcim/migrations/0213_platform_parent.py

@@ -6,7 +6,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0210_interface_tx_power_negative'),
+        ('dcim', '0212_interface_tx_power_negative'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0212_platform_rebuild.py → netbox/dcim/migrations/0214_platform_rebuild.py

@@ -18,7 +18,7 @@ def rebuild_mptt(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0211_platform_parent'),
+        ('dcim', '0213_platform_parent'),
     ]
 
     operations = [

+ 1 - 1
netbox/dcim/migrations/0213_rackreservation_status.py → netbox/dcim/migrations/0215_rackreservation_status.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0212_platform_rebuild'),
+        ('dcim', '0214_platform_rebuild'),
     ]
 
     operations = [

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

@@ -12,6 +12,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node
+from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
 from utilities.exceptions import AbortRequest
@@ -156,6 +157,15 @@ class Cable(PrimaryModel):
             self._terminations_modified = True
         self._b_terminations = value
 
+    @property
+    def color_name(self):
+        color_name = ""
+        for hex_code, label in ColorChoices.CHOICES:
+            if hex_code.lower() == self.color.lower():
+                color_name = str(label)
+
+        return color_name
+
     def clean(self):
         super().clean()
 

+ 31 - 0
netbox/dcim/models/device_components.py

@@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
         blank=True
     )
 
+    # Denormalized references replicated from the parent Device
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+    _rack = models.ForeignKey(
+        to='dcim.Rack',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+
     class Meta:
         abstract = True
         ordering = ('device', 'name')
@@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
                 "device": _("Components cannot be moved to a different device.")
             })
 
+    def save(self, *args, **kwargs):
+        # Save denormalized references
+        self._site = self.device.site
+        self._location = self.device.location
+        self._rack = self.device.rack
+
+        super().save(*args, **kwargs)
+
     @property
     def parent_object(self):
         return self.device

+ 7 - 3
netbox/dcim/models/devices.py

@@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
-from django.db.models import F, ProtectedError
+from django.db.models import F, ProtectedError, prefetch_related_objects
 from django.db.models.functions import Lower
 from django.db.models.signals import post_save
 from django.urls import reverse
@@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField
+from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .mixins import RenderConfigMixin
@@ -957,7 +958,10 @@ class Device(
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 for component in components:
                     component.custom_field_data = cf_defaults
-            model.objects.bulk_create(components)
+            components = model.objects.bulk_create(components)
+            # Prefetch related objects to minimize queries needed during post_save
+            prefetch_fields = get_prefetchable_fields(model)
+            prefetch_related_objects(components, *prefetch_fields)
             # Manually send the post_save signal for each of the newly created components
             for component in components:
                 post_save.send(
@@ -1305,7 +1309,7 @@ class MACAddress(PrimaryModel):
     )
 
     class Meta:
-        ordering = ('mac_address',)
+        ordering = ('mac_address', 'pk',)
         verbose_name = _('MAC address')
         verbose_name_plural = _('MAC addresses')
 

+ 31 - 2
netbox/dcim/signals.py

@@ -3,13 +3,28 @@ import logging
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 
-from .choices import CableEndChoices, LinkStatusChoices
+from dcim.choices import CableEndChoices, LinkStatusChoices
 from .models import (
-    Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
+    Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
+    InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
+    VirtualChassis,
 )
 from .models.cables import trace_paths
 from .utils import create_cablepath, rebuild_paths
 
+COMPONENT_MODELS = (
+    ConsolePort,
+    ConsoleServerPort,
+    DeviceBay,
+    FrontPort,
+    Interface,
+    InventoryItem,
+    ModuleBay,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+)
+
 
 #
 # Location/rack/device assignment
@@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
         Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
 
 
+@receiver(post_save, sender=Device)
+def handle_device_site_change(instance, created, **kwargs):
+    """
+    Update child components to update the parent Site, Location, and Rack when a Device is saved.
+    """
+    if not created:
+        for model in COMPONENT_MODELS:
+            model.objects.filter(device=instance).update(
+                _site=instance.site,
+                _location=instance.location,
+                _rack=instance.rack,
+            )
+
+
 #
 # Virtual chassis
 #

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

@@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('_abs_length')
     )
     color = columns.ColorColumn()
+    color_name = tables.Column(
+        verbose_name=_('Color Name'),
+        orderable=False
+    )
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
         url_name='dcim:cable_list'
@@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         fields = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
             'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
-            'length', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

+ 188 - 17
netbox/dcim/tests/test_filtersets.py

@@ -3438,9 +3438,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         ConsoleServerPort.objects.bulk_create(console_server_ports)
 
         console_ports = (
-            ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
-            ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
-            ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
+            ConsolePort(
+                device=devices[0],
+                module=modules[0],
+                name='Console Port 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            ConsolePort(
+                device=devices[1],
+                module=modules[1],
+                name='Console Port 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            ConsolePort(
+                device=devices[2],
+                module=modules[2],
+                name='Console Port 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
         )
         ConsolePort.objects.bulk_create(console_ports)
 
@@ -3652,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
 
         console_server_ports = (
             ConsoleServerPort(
-                device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
+                device=devices[0],
+                module=modules[0],
+                name='Console Server Port 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ConsoleServerPort(
-                device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
+                device=devices[1],
+                module=modules[1],
+                name='Console Server Port 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ConsoleServerPort(
-                device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
+                device=devices[2],
+                module=modules[2],
+                name='Console Server Port 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -3878,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=100,
                 allocated_draw=50,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             PowerPort(
                 device=devices[1],
@@ -3887,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=200,
                 allocated_draw=100,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             PowerPort(
                 device=devices[2],
@@ -3896,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=300,
                 allocated_draw=150,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         PowerPort.objects.bulk_create(power_ports)
@@ -4124,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='First',
                 color='ff0000',
                 status=PowerOutletStatusChoices.STATUS_ENABLED,
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             PowerOutlet(
                 device=devices[1],
@@ -4134,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Second',
                 color='00ff00',
                 status=PowerOutletStatusChoices.STATUS_DISABLED,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             PowerOutlet(
                 device=devices[2],
@@ -4144,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Third',
                 color='0000ff',
                 status=PowerOutletStatusChoices.STATUS_FAULTY,
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         PowerOutlet.objects.bulk_create(power_outlets)
@@ -4452,13 +4518,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             Interface(
                 device=devices[1],
                 module=modules[1],
                 name='VC Chassis Interface',
                 type=InterfaceTypeChoices.TYPE_1GE_SFP,
-                enabled=True
+                enabled=True,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             Interface(
                 device=devices[2],
@@ -4477,6 +4549,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             Interface(
                 device=devices[3],
@@ -4495,6 +4570,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4511,6 +4589,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 qinq_svlan=vlans[0],
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4521,7 +4602,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=True,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
-                qinq_svlan=vlans[1]
+                qinq_svlan=vlans[1],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4532,7 +4616,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=False,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
-                qinq_svlan=vlans[2]
+                qinq_svlan=vlans[2],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4541,7 +4628,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_AP,
                 rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
                 rf_channel_frequency=2412,
-                rf_channel_width=22
+                rf_channel_width=22,
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4550,7 +4640,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_STATION,
                 rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
                 rf_channel_frequency=5160,
-                rf_channel_width=20
+                rf_channel_width=20,
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
         )
         Interface.objects.bulk_create(interfaces)
@@ -4977,6 +5070,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[0],
                 rear_port_position=1,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             FrontPort(
                 device=devices[1],
@@ -4988,6 +5084,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[1],
                 rear_port_position=2,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             FrontPort(
                 device=devices[2],
@@ -4999,6 +5098,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[2],
                 rear_port_position=3,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -5007,6 +5109,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[3],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -5015,6 +5120,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[4],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -5023,6 +5131,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[5],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
         )
         FrontPort.objects.bulk_create(front_ports)
@@ -5239,6 +5350,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_RED,
                 positions=1,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             RearPort(
                 device=devices[1],
@@ -5249,6 +5363,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_GREEN,
                 positions=2,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             RearPort(
                 device=devices[2],
@@ -5259,10 +5376,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_BLUE,
                 positions=3,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 4',
+                label='D',
+                type=PortTypeChoices.TYPE_FC,
+                positions=4,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 5',
+                label='E',
+                type=PortTypeChoices.TYPE_FC,
+                positions=5,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 6',
+                label='F',
+                type=PortTypeChoices.TYPE_FC,
+                positions=6,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
-            RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
-            RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
-            RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
         )
         RearPort.objects.bulk_create(rear_ports)
 
@@ -5621,9 +5768,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         Device.objects.bulk_create(devices)
 
         device_bays = (
-            DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
-            DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
-            DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
+            DeviceBay(
+                device=devices[0],
+                name='Device Bay 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            DeviceBay(
+                device=devices[1],
+                name='Device Bay 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            DeviceBay(
+                device=devices[2],
+                name='Device Bay 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
         )
         DeviceBay.objects.bulk_create(device_bays)
 

+ 8 - 1
netbox/extras/dashboard/widgets.py

@@ -307,6 +307,7 @@ class RSSFeedWidget(DashboardWidget):
     default_config = {
         'max_entries': 10,
         'cache_timeout': 3600,  # seconds
+        'request_timeout': 3,  # seconds
         'requires_internet': True,
     }
     description = _('Embed an RSS feed from an external website.')
@@ -333,6 +334,12 @@ class RSSFeedWidget(DashboardWidget):
             max_value=86400,  # 24 hours
             help_text=_('How long to stored the cached content (in seconds)')
         )
+        request_timeout = forms.IntegerField(
+            min_value=1,
+            max_value=60,
+            required=False,
+            help_text=_('Timeout value for fetching the feed (in seconds)')
+        )
 
     def render(self, request):
         return render_to_string(self.template_name, {
@@ -364,7 +371,7 @@ class RSSFeedWidget(DashboardWidget):
                 url=self.config['feed_url'],
                 headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
                 proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
-                timeout=3
+                timeout=self.config.get('request_timeout', 3),
             )
             response.raise_for_status()
         except requests.exceptions.RequestException as e:

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

@@ -872,6 +872,9 @@ class Bookmark(models.Model):
             return str(self.object)
         return super().__str__()
 
+    def get_absolute_url(self):
+        return reverse('account:bookmarks')
+
     def clean(self):
         super().clean()
 

+ 18 - 0
netbox/extras/tests/test_views.py

@@ -807,3 +807,21 @@ class NotificationTestCase(
 
     def test_list_objects_with_constrained_permission(self):
         return
+
+
+class ScriptListViewTest(TestCase):
+    user_permissions = ['extras.view_script']
+
+    def test_script_list_embedded_parameter(self):
+        """Test that ScriptListView accepts embedded parameter without error"""
+        url = reverse('extras:script_list')
+
+        # Test normal request
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'extras/script_list.html')
+
+        # Test embedded request
+        response = self.client.get(url, {'embedded': 'true'})
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')

+ 10 - 3
netbox/extras/views.py

@@ -1317,11 +1317,18 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
             'data_source', 'data_file', 'jobs'
         )
-
-        return render(request, 'extras/script_list.html', {
+        context = {
             'model': ScriptModule,
             'script_modules': script_modules,
-        })
+        }
+
+        # Use partial template for dashboard widgets
+        template_name = 'extras/script_list.html'
+        if request.GET.get('embedded'):
+            template_name = 'extras/inc/script_list_content.html'
+            context['embedded'] = True
+
+        return render(request, template_name, context)
 
 
 class BaseScriptView(generic.ObjectView):

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

@@ -22,7 +22,7 @@ from utilities.forms.utils import get_field_value
 from utilities.forms.widgets import DatePicker, HTMXSelect
 from django.utils.safestring import mark_safe
 from utilities.templatetags.builtins.filters import bettertitle
-from virtualization.models import VMInterface
+from virtualization.models import VMInterface, VirtualMachine
 
 __all__ = (
     'AggregateForm',
@@ -792,10 +792,6 @@ class ServiceForm(NetBoxModelForm):
         queryset=IPAddress.objects.all(),
         required=False,
         label=_('IP Addresses'),
-        query_params={
-            'device_id': '$device',
-            'virtual_machine_id': '$virtual_machine',
-        }
     )
     comments = CommentField()
 
@@ -824,10 +820,22 @@ class ServiceForm(NetBoxModelForm):
 
         super().__init__(*args, **kwargs)
 
-        if (parent_object_type_id := get_field_value(self, 'parent_object_type')):
+        if parent_object_type_id := get_field_value(self, 'parent_object_type'):
             try:
                 parent_type = ContentType.objects.get(pk=parent_object_type_id)
                 model = parent_type.model_class()
+                if model == Device:
+                    self.fields['ipaddresses'].widget.add_query_params({
+                        'device_id': '$parent',
+                    })
+                elif model == VirtualMachine:
+                    self.fields['ipaddresses'].widget.add_query_params({
+                        'virtual_machine_id': '$parent',
+                    })
+                elif model == FHRPGroup:
+                    self.fields['ipaddresses'].widget.add_query_params({
+                        'fhrpgroup_id': '$parent',
+                    })
                 self.fields['parent'].queryset = model.objects.all()
                 self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
                 self.fields['parent'].disabled = False

+ 23 - 0
netbox/ipam/graphql/filters.py

@@ -222,6 +222,19 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
                 return Q()
         return q
 
+    @strawberry_django.filter_field()
+    def contains(self, value: list[str], prefix) -> Q:
+        if not value:
+            return Q()
+        q = Q()
+        for subnet in value:
+            net = netaddr.IPNetwork(subnet.strip())
+            q |= Q(
+                start_address__host__inet__lte=str(netaddr.IPAddress(net.first)),
+                end_address__host__inet__gte=str(netaddr.IPAddress(net.last)),
+            )
+        return q
+
 
 @strawberry_django.filter_type(models.Prefix, lookups=True)
 class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
@@ -238,6 +251,16 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
     is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()
     mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
 
+    @strawberry_django.filter_field()
+    def contains(self, value: list[str], prefix) -> Q:
+        if not value:
+            return Q()
+        q = Q()
+        for subnet in value:
+            query = str(netaddr.IPNetwork(subnet.strip()).cidr)
+            q |= Q(prefix__net_contains=query)
+        return q
+
 
 @strawberry_django.filter_type(models.RIR, lookups=True)
 class RIRFilter(OrganizationalModelFilterMixin):

+ 20 - 0
netbox/ipam/migrations/0082_add_prefix_network_containment_indexes.py

@@ -0,0 +1,20 @@
+from django.contrib.postgres.indexes import GistIndex
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0210_macaddress_ordering'),
+        ('extras', '0129_fix_script_paths'),
+        ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'),
+        ('tenancy', '0020_remove_contactgroupmembership'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='prefix',
+            index=GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
+        ),
+    ]

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

@@ -1,6 +1,7 @@
 import netaddr
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import F
@@ -281,6 +282,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk')  # (vrf, prefix) may be non-unique
         verbose_name = _('prefix')
         verbose_name_plural = _('prefixes')
+        indexes = [
+            GistIndex(
+                fields=['prefix'],
+                name='ipam_prefix_gist_idx',
+                opclasses=['inet_ops'],
+            ),
+        ]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)

+ 5 - 1
netbox/netbox/tables/columns.py

@@ -237,7 +237,11 @@ class ActionsColumn(tables.Column):
     :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
         direct button link and icon (default: True)
     """
-    attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
+    attrs = {
+        'td': {
+            'class': 'text-end text-nowrap noprint p-1'
+        }
+    }
     empty_values = ()
     actions = {
         'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),

File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.css


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 38 - 11
netbox/project-static/src/buttons/moveOptions.ts

@@ -1,5 +1,20 @@
 import { getElements } from '../util';
 
+/**
+ * Move selected options from one select element to another.
+ *
+ * @param source Select Element
+ * @param target Select Element
+ */
+function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
+  for (const option of Array.from(source.options)) {
+    if (option.selected) {
+      target.appendChild(option.cloneNode(true));
+      option.remove();
+    }
+  }
+}
+
 /**
  * Move selected options of a select element up in order.
  *
@@ -39,23 +54,35 @@ function moveOptionDown(element: HTMLSelectElement): void {
 }
 
 /**
- * Initialize move up/down buttons.
+ * Initialize select/move buttons.
  */
 export function initMoveButtons(): void {
-  for (const button of getElements<HTMLButtonElement>('#move-option-up')) {
+  // Move selected option(s) between lists
+  for (const button of getElements<HTMLButtonElement>('.move-option')) {
+    const source = button.getAttribute('data-source');
+    const target = button.getAttribute('data-target');
+    const source_select = document.getElementById(`id_${source}`) as HTMLSelectElement;
+    const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement;
+    if (source_select !== null && target_select !== null) {
+      button.addEventListener('click', () => moveOption(source_select, target_select));
+    }
+  }
+
+  // Move selected option(s) up in current list
+  for (const button of getElements<HTMLButtonElement>('.move-option-up')) {
     const target = button.getAttribute('data-target');
-    if (target !== null) {
-      for (const select of getElements<HTMLSelectElement>(`#${target}`)) {
-        button.addEventListener('click', () => moveOptionUp(select));
-      }
+    const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement;
+    if (target_select !== null) {
+      button.addEventListener('click', () => moveOptionUp(target_select));
     }
   }
-  for (const button of getElements<HTMLButtonElement>('#move-option-down')) {
+
+  // Move selected option(s) down in current list
+  for (const button of getElements<HTMLButtonElement>('.move-option-down')) {
     const target = button.getAttribute('data-target');
-    if (target !== null) {
-      for (const select of getElements<HTMLSelectElement>(`#${target}`)) {
-        button.addEventListener('click', () => moveOptionDown(select));
-      }
+    const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement;
+    if (target_select !== null) {
+      button.addEventListener('click', () => moveOptionDown(target_select));
     }
   }
 }

+ 3 - 1
netbox/project-static/src/search.ts

@@ -38,7 +38,9 @@ function handleQuickSearchParams(event: Event): void {
 
   if (quickSearchParameters != null) {
     const link = document.getElementById('export_current_view') as HTMLLinkElement;
-    const search_parameter = `q=${quickSearchParameters.value}`;
+    const params = new URLSearchParams();
+    params.set('q', quickSearchParameters.value);
+    const search_parameter = params.toString();
     const linkUpdated = link?.href + '&' + search_parameter;
     link.setAttribute('href', linkUpdated);
   }

+ 2 - 0
netbox/project-static/styles/custom/_code.scss

@@ -8,6 +8,8 @@ pre.change-data {
     display: block;
     padding-right: $spacer;
     padding-left: $spacer;
+    width: 100%;
+    min-width: fit-content;
 
     &.added {
       background-color: $green;

+ 65 - 65
netbox/project-static/yarn.lock

@@ -845,78 +845,78 @@
     "@types/estree" "*"
 
 "@typescript-eslint/eslint-plugin@^8.37.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz#6e5220d16f2691ab6d983c1737dd5b36e17641b7"
-  integrity sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz#28dffcb5272d20afe250bfeec3173263db5528a0"
+  integrity sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==
   dependencies:
     "@eslint-community/regexpp" "^4.10.0"
-    "@typescript-eslint/scope-manager" "8.38.0"
-    "@typescript-eslint/type-utils" "8.38.0"
-    "@typescript-eslint/utils" "8.38.0"
-    "@typescript-eslint/visitor-keys" "8.38.0"
+    "@typescript-eslint/scope-manager" "8.39.1"
+    "@typescript-eslint/type-utils" "8.39.1"
+    "@typescript-eslint/utils" "8.39.1"
+    "@typescript-eslint/visitor-keys" "8.39.1"
     graphemer "^1.4.0"
     ignore "^7.0.0"
     natural-compare "^1.4.0"
     ts-api-utils "^2.1.0"
 
 "@typescript-eslint/parser@^8.37.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.38.0.tgz#6723a5ea881e1777956b1045cba30be5ea838293"
-  integrity sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==
-  dependencies:
-    "@typescript-eslint/scope-manager" "8.38.0"
-    "@typescript-eslint/types" "8.38.0"
-    "@typescript-eslint/typescript-estree" "8.38.0"
-    "@typescript-eslint/visitor-keys" "8.38.0"
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.39.1.tgz#7f8f9ecfc7e172d67e42c366fa198e42324e5d50"
+  integrity sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==
+  dependencies:
+    "@typescript-eslint/scope-manager" "8.39.1"
+    "@typescript-eslint/types" "8.39.1"
+    "@typescript-eslint/typescript-estree" "8.39.1"
+    "@typescript-eslint/visitor-keys" "8.39.1"
     debug "^4.3.4"
 
-"@typescript-eslint/project-service@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.38.0.tgz#4900771f943163027fd7d2020a062892056b5e2f"
-  integrity sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==
+"@typescript-eslint/project-service@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.39.1.tgz#63525878d488ebf27c485f295e83434a1398f52d"
+  integrity sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.38.0"
-    "@typescript-eslint/types" "^8.38.0"
+    "@typescript-eslint/tsconfig-utils" "^8.39.1"
+    "@typescript-eslint/types" "^8.39.1"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz#5a0efcb5c9cf6e4121b58f87972f567c69529226"
-  integrity sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==
+"@typescript-eslint/scope-manager@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz#1253fe3e1f2f33f08a3e438a05b5dd7faf9fbca6"
+  integrity sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==
   dependencies:
-    "@typescript-eslint/types" "8.38.0"
-    "@typescript-eslint/visitor-keys" "8.38.0"
+    "@typescript-eslint/types" "8.39.1"
+    "@typescript-eslint/visitor-keys" "8.39.1"
 
-"@typescript-eslint/tsconfig-utils@8.38.0", "@typescript-eslint/tsconfig-utils@^8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz#6de4ce224a779601a8df667db56527255c42c4d0"
-  integrity sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==
+"@typescript-eslint/tsconfig-utils@8.39.1", "@typescript-eslint/tsconfig-utils@^8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz#17f13b4ad481e7bec7c249ee1854078645b34b12"
+  integrity sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==
 
-"@typescript-eslint/type-utils@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz#a56cd84765fa6ec135fe252b5db61e304403a85b"
-  integrity sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==
+"@typescript-eslint/type-utils@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz#642f9fb96173649e2928fea0375b1d74d31906c2"
+  integrity sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==
   dependencies:
-    "@typescript-eslint/types" "8.38.0"
-    "@typescript-eslint/typescript-estree" "8.38.0"
-    "@typescript-eslint/utils" "8.38.0"
+    "@typescript-eslint/types" "8.39.1"
+    "@typescript-eslint/typescript-estree" "8.39.1"
+    "@typescript-eslint/utils" "8.39.1"
     debug "^4.3.4"
     ts-api-utils "^2.1.0"
 
-"@typescript-eslint/types@8.38.0", "@typescript-eslint/types@^8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.38.0.tgz#297351c994976b93c82ac0f0e206c8143aa82529"
-  integrity sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==
+"@typescript-eslint/types@8.39.1", "@typescript-eslint/types@^8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.1.tgz#f0ab996c8ab2c3b046bbf86bb1990b03529869a1"
+  integrity sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==
 
-"@typescript-eslint/typescript-estree@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz#82262199eb6778bba28a319e25ad05b1158957df"
-  integrity sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==
+"@typescript-eslint/typescript-estree@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz#8825d3ea7ea2144c577859ae489eec24ef7318a5"
+  integrity sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==
   dependencies:
-    "@typescript-eslint/project-service" "8.38.0"
-    "@typescript-eslint/tsconfig-utils" "8.38.0"
-    "@typescript-eslint/types" "8.38.0"
-    "@typescript-eslint/visitor-keys" "8.38.0"
+    "@typescript-eslint/project-service" "8.39.1"
+    "@typescript-eslint/tsconfig-utils" "8.39.1"
+    "@typescript-eslint/types" "8.39.1"
+    "@typescript-eslint/visitor-keys" "8.39.1"
     debug "^4.3.4"
     fast-glob "^3.3.2"
     is-glob "^4.0.3"
@@ -924,22 +924,22 @@
     semver "^7.6.0"
     ts-api-utils "^2.1.0"
 
-"@typescript-eslint/utils@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.38.0.tgz#5f10159899d30eb92ba70e642ca6f754bddbf15a"
-  integrity sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==
+"@typescript-eslint/utils@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.39.1.tgz#58a834f89f93b786ada2cd14d77fa63c3c8f408b"
+  integrity sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==
   dependencies:
     "@eslint-community/eslint-utils" "^4.7.0"
-    "@typescript-eslint/scope-manager" "8.38.0"
-    "@typescript-eslint/types" "8.38.0"
-    "@typescript-eslint/typescript-estree" "8.38.0"
+    "@typescript-eslint/scope-manager" "8.39.1"
+    "@typescript-eslint/types" "8.39.1"
+    "@typescript-eslint/typescript-estree" "8.39.1"
 
-"@typescript-eslint/visitor-keys@8.38.0":
-  version "8.38.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz#a9765a527b082cb8fc60fd8a16e47c7ad5b60ea5"
-  integrity sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==
+"@typescript-eslint/visitor-keys@8.39.1":
+  version "8.39.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz#a467742a98f2fa3c03d7bed4979dc0db3850a77a"
+  integrity sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==
   dependencies:
-    "@typescript-eslint/types" "8.38.0"
+    "@typescript-eslint/types" "8.39.1"
     eslint-visitor-keys "^4.2.1"
 
 "@ungap/structured-clone@^1.2.0":
@@ -1742,9 +1742,9 @@ eslint-plugin-import@^2.32.0:
     tsconfig-paths "^3.15.0"
 
 eslint-plugin-prettier@^5.5.1:
-  version "5.5.3"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz#1f88e9220a72ac8be171eec5f9d4e4d529b5f4a0"
-  integrity sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==
+  version "5.5.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz#9d61c4ea11de5af704d4edf108c82ccfa7f2e61c"
+  integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==
   dependencies:
     prettier-linter-helpers "^1.0.0"
     synckit "^0.11.7"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.3.5"
+version: "4.3.6"
 edition: "Community"
-published: "2025-07-29"
+published: "2025-08-12"

+ 5 - 5
netbox/templates/circuits/inc/circuit_termination.html

@@ -4,22 +4,22 @@
 <div class="card">
     <h2 class="card-header d-flex justify-content-between">
       {% blocktrans %}Termination{% endblocktrans %} {{ side }}
-      <div>
+      <div class="card-actions">
         {% if not termination and perms.circuits.add_circuittermination %}
-            <a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-success lh-1">
+            <a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-primary">
                 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add" %}
             </a>
         {% endif %}
         {% if termination and perms.circuits.change_circuittermination %}
-            <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning lh-1">
+            <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-warning">
                 <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
             </a>
-            <a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-primary lh-1">
+            <a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-primary">
                 <span class="mdi mdi-swap-vertical" aria-hidden="true"></span> {% trans "Swap" %}
             </a>
         {% endif %}
         {% if termination and perms.circuits.delete_circuittermination %}
-            <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger lh-1">
+            <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-danger">
                 <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
             </a>
         {% endif %}

+ 3 - 3
netbox/templates/circuits/inc/circuit_termination_fields.html

@@ -29,16 +29,16 @@
           {{ peer|linkify }}{% if not forloop.last %},{% endif %}
         {% endfor %}
         <div class="mt-1">
-          <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+          <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
             <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
           </a>
           {% if perms.dcim.change_cable %}
-            <a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
+            <a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-sm btn-warning">
               <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
             </a>
           {% endif %}
           {% if perms.dcim.delete_cable %}
-            <a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
+            <a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-sm btn-danger">
               <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
             </a>
           {% endif %}

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

@@ -77,7 +77,7 @@
                             <th scope="row">{% trans "Cable" %}</th>
                             <td>
                                 {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:frontport_trace' pk=object.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                                <a href="{% url 'dcim:frontport_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
                                     <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
                                 </a>
                             </td>

+ 3 - 3
netbox/templates/dcim/inc/cable_termination.html

@@ -23,7 +23,7 @@
             {{ term.device|linkify }}
             <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
             {{ term|linkify }}
-            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
               <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
             </a>
             {% if not forloop.last %}<br/>{% endif %}
@@ -45,7 +45,7 @@
         <td>
           {% for term in terminations %}
             {{ term|linkify }}
-            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
               <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
             </a>
             {% if not forloop.last %}<br/>{% endif %}
@@ -63,7 +63,7 @@
         <td>
           {% for term in terminations %}
             {{ term.circuit|linkify }} ({{ term }})
-            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+            <a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
               <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
             </a>
             {% if not forloop.last %}<br/>{% endif %}

+ 1 - 1
netbox/templates/dcim/inc/connection_endpoints.html

@@ -4,7 +4,7 @@
     <th scope="row">{% trans "Cable" %}</th>
     <td>
       {{ object.cable|linkify }}
-      <a href="{% url trace_url pk=object.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+      <a href="{% url trace_url pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
         <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
       </a>
     </td>

+ 2 - 2
netbox/templates/dcim/inc/panels/inventory_items.html

@@ -29,12 +29,12 @@
           <td>{{ item.role|linkify|placeholder }}</td>
           <td class="text-end d-print-none">
             {% if perms.dcim.change_inventoryitem %}
-              <a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning lh-1" title="{% trans "Edit" %}">
+              <a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning" title="{% trans "Edit" %}">
                 <i class="mdi mdi-pencil" aria-hidden="true"></i>
               </a>
             {% endif %}
             {% if perms.ipam.delete_inventoryitem %}
-              <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger lh-1" title="{% trans "Delete" %}">
+              <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger" title="{% trans "Delete" %}">
                 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
               </a>
             {% endif %}

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

@@ -208,7 +208,7 @@
                 <th scope="row">{% trans "Wireless Link" %}</th>
                 <td>
                   {{ object.wireless_link|linkify }}
-                  <a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                  <a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
                     <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
                   </a>
                 </td>

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

@@ -73,7 +73,7 @@
                             <th scope="row">{% trans "Cable" %}</th>
                             <td>
                                 {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:rearport_trace' pk=object.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                                <a href="{% url 'dcim:rearport_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
                                     <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
                                 </a>
                             </td>

+ 1 - 1
netbox/templates/extras/htmx/script_result.html

@@ -57,7 +57,7 @@
           {% trans "Output" %}
           {% if job.completed %}
             <div>
-              <a href="?export=output" class="btn btn-primary lh-1" role="button">
+              <a href="?export=output" class="btn btn-sm btn-primary" role="button">
                 <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
               </a>
             </div>

+ 139 - 0
netbox/templates/extras/inc/script_list_content.html

@@ -0,0 +1,139 @@
+{% load buttons %}
+{% load helpers %}
+{% load perms %}
+{% load i18n %}
+
+{# Core script list content - used by both full page and embedded views #}
+{% for module in script_modules %}
+  {% include 'inc/sync_warning.html' with object=module %}
+  <div class="card{% if embedded %} mb-3{% endif %}">
+    {% if not embedded %}
+      <h2 class="card-header" id="module{{ module.pk }}">
+        <i class="mdi mdi-file-document-outline"></i> {{ module }}
+        <div class="card-actions">
+          {% if perms.extras.edit_scriptmodule %}
+            <a href="{% url 'extras:scriptmodule_edit' pk=module.pk %}" class="btn btn-ghost-warning btn-sm">
+              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+            </a>
+          {% endif %}
+          {% if perms.extras.delete_scriptmodule %}
+            <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-ghost-danger btn-sm">
+              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
+            </a>
+          {% endif %}
+        </div>
+      </h2>
+    {% endif %}
+    {% with scripts=module.ordered_scripts %}
+      {% if scripts %}
+        <table class="table table-hover scripts{% if embedded %} object-list table-sm{% endif %}">
+          <thead>
+            <tr>
+              <th>{% trans "Name" %}</th>
+              <th>{% trans "Description" %}</th>
+              <th>{% trans "Last Run" %}</th>
+              <th>{% trans "Status" %}</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for script in scripts %}
+              {% with last_job=script.get_latest_jobs|first %}
+                <tr>
+                  <td>
+                    {% if script.is_executable %}
+                      <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
+                    {% else %}
+                      <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
+                      <span class="text-danger">
+                        <i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
+                      </span>
+                    {% endif %}
+                  </td>
+                  <td>{{ script.python_class.description|markdown|placeholder }}</td>
+                  {% if last_job %}
+                    <td>
+                      <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
+                    </td>
+                    <td>
+                      {% badge last_job.get_status_display last_job.get_status_color %}
+                    </td>
+                  {% else %}
+                    <td class="text-muted">{% trans "Never" %}</td>
+                    <td>{{ ''|placeholder }}</td>
+                  {% endif %}
+                  <td>
+                    {% if request.user|can_run:script and script.is_executable %}
+                      <div class="float-end d-print-none">
+                        <form action="{% url 'extras:script' script.pk %}" method="post">
+                          {% if script.python_class.commit_default %}
+                            <input type="checkbox" name="_commit" hidden checked>
+                          {% endif %}
+                          {% csrf_token %}
+                          <button type="submit" name="_run" class="btn btn-primary{% if embedded %} btn-sm{% endif %}">
+                            {% if last_job %}
+                              <i class="mdi mdi-replay"></i> {% if not embedded %}{% trans "Run Again" %}{% endif %}
+                            {% else %}
+                              <i class="mdi mdi-play"></i> {% if not embedded %}{% trans "Run Script" %}{% endif %}
+                            {% endif %}
+                          </button>
+                        </form>
+                      </div>
+                    {% endif %}
+                  </td>
+                </tr>
+                {% if last_job and not embedded %}
+                  {% for test_name, data in last_job.data.tests.items %}
+                    <tr>
+                      <td colspan="4" class="method">
+                        <span class="ps-3">{{ test_name }}</span>
+                      </td>
+                      <td class="text-end text-nowrap script-stats">
+                        <span class="badge text-bg-success">{{ data.success }}</span>
+                        <span class="badge text-bg-info">{{ data.info }}</span>
+                        <span class="badge text-bg-warning">{{ data.warning }}</span>
+                        <span class="badge text-bg-danger">{{ data.failure }}</span>
+                      </td>
+                    </tr>
+                  {% endfor %}
+                {% elif last_job and not last_job.data.log and not embedded %}
+                  {# legacy #}
+                  {% for method, stats in last_job.data.items %}
+                    <tr>
+                      <td colspan="4" class="method">
+                        <span class="ps-3">{{ method }}</span>
+                      </td>
+                      <td class="text-end text-nowrap report-stats">
+                        <span class="badge bg-success">{{ stats.success }}</span>
+                        <span class="badge bg-info">{{ stats.info }}</span>
+                        <span class="badge bg-warning">{{ stats.warning }}</span>
+                        <span class="badge bg-danger">{{ stats.failure }}</span>
+                      </td>
+                    </tr>
+                  {% endfor %}
+                {% endif %}
+              {% endwith %}
+            {% endfor %}
+          </tbody>
+        </table>
+      {% else %}
+        <div class="card-body">
+          <div class="alert alert-warning" role="alert">
+            <i class="mdi mdi-alert"></i>
+            {% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
+          </div>
+        </div>
+      {% endif %}
+    {% endwith %}
+  </div>
+{% empty %}
+  <div class="alert alert-info" role="alert">
+    <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
+    {% if perms.extras.add_scriptmodule and not embedded %}
+      {% url 'extras:scriptmodule_add' as create_script_url %}
+      {% blocktrans trimmed %}
+        Get started by <a href="{{ create_script_url }}">creating a script</a> from an uploaded file or data source.
+      {% endblocktrans %}
+    {% endif %}
+  </div>
+{% endfor %}

+ 3 - 3
netbox/templates/extras/object_render_config.html

@@ -54,11 +54,11 @@
           <div class="card">
             <h2 class="card-header d-flex justify-content-between">
               {% trans "Rendered Config" %}
-              <div>
-                {% copy_content "rendered_config" %}
-                <a href="?export=True" class="btn btn-primary lh-1" role="button">
+              <div class="card-actions">
+                <a href="?export=True" class="btn btn-sm btn-ghost-primary" role="button">
                   <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
                 </a>
+                {% copy_content "rendered_config" %}
               </div>
             </h2>
             <pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>

+ 4 - 0
netbox/templates/extras/schema/devicetype_schema.jinja2

@@ -70,6 +70,10 @@
         "poe_type": {
           "type": "string",
           "enum": {{ interface_poe_type_choices }}
+        },
+        "rf_role": {
+          "type": "string",
+          "enum": {{ interface_rf_role_choices }}
         }
       }
     },

+ 1 - 131
netbox/templates/extras/script_list.html

@@ -19,135 +19,5 @@
 {% endblock controls %}
 
 {% block content %}
-  {% for module in script_modules %}
-    {% include 'inc/sync_warning.html' with object=module %}
-    <div class="card">
-      <h2 class="card-header" id="module{{ module.pk }}">
-        <i class="mdi mdi-file-document-outline"></i> {{ module }}
-        <div class="card-actions">
-          {% if perms.extras.edit_scriptmodule %}
-            <a href="{% url 'extras:scriptmodule_edit' pk=module.pk %}" class="btn btn-ghost-warning btn-sm">
-              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
-            </a>
-          {% endif %}
-          {% if perms.extras.delete_scriptmodule %}
-            <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-ghost-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </a>
-          {% endif %}
-        </div>
-      </h2>
-      {% with scripts=module.ordered_scripts %}
-        {% if scripts %}
-          <table class="table table-hover scripts">
-            <thead>
-              <tr>
-                <th>{% trans "Name" %}</th>
-                <th>{% trans "Description" %}</th>
-                <th>{% trans "Last Run" %}</th>
-                <th>{% trans "Status" %}</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody>
-              {% for script in scripts %}
-                {% with last_job=script.get_latest_jobs|first %}
-                  <tr>
-                    <td>
-                      {% if script.is_executable %}
-                        <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
-                      {% else %}
-                        <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
-                        <span class="text-danger">
-                          <i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
-                        </span>
-                      {% endif %}
-                    </td>
-                    <td>{{ script.python_class.description|markdown|placeholder }}</td>
-                    {% if last_job %}
-                      <td>
-                        <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
-                      </td>
-                      <td>
-                        {% badge last_job.get_status_display last_job.get_status_color %}
-                      </td>
-                    {% else %}
-                      <td class="text-muted">{% trans "Never" %}</td>
-                      <td>{{ ''|placeholder }}</td>
-                    {% endif %}
-                    <td>
-                      {% if request.user|can_run:script and script.is_executable %}
-                        <div class="float-end d-print-none">
-                          <form action="{% url 'extras:script' script.pk %}" method="post">
-                            {% if script.python_class.commit_default %}
-                              <input type="checkbox" name="_commit" hidden checked>
-                            {% endif %}
-                            {% csrf_token %}
-                            <button type="submit" name="_run" class="btn btn-primary btn-sm">
-                              {% if last_job %}
-                                <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                              {% else %}
-                                <i class="mdi mdi-play"></i> {% trans "Run Script" %}
-                              {% endif %}
-                            </button>
-                          </form>
-                        </div>
-                      {% endif %}
-                    </td>
-                  </tr>
-                  {% if last_job %}
-                    {% for test_name, data in last_job.data.tests.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ test_name }}</span>
-                        </td>
-                        <td class="text-end text-nowrap script-stats">
-                          <span class="badge text-bg-success">{{ data.success }}</span>
-                          <span class="badge text-bg-info">{{ data.info }}</span>
-                          <span class="badge text-bg-warning">{{ data.warning }}</span>
-                          <span class="badge text-bg-danger">{{ data.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
-                  {% elif not last_job.data.log %}
-                    {# legacy #}
-                    {% for method, stats in last_job.data.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ method }}</span>
-                        </td>
-                        <td class="text-end text-nowrap report-stats">
-                          <span class="badge bg-success">{{ stats.success }}</span>
-                          <span class="badge bg-info">{{ stats.info }}</span>
-                          <span class="badge bg-warning">{{ stats.warning }}</span>
-                          <span class="badge bg-danger">{{ stats.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
-                  {% endif %}
-                {% endwith %}
-              {% endfor %}
-            </tbody>
-          </table>
-        {% else %}
-          <div class="card-body">
-            <div class="alert alert-warning" role="alert">
-              <i class="mdi mdi-alert"></i>
-              {% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
-            </div>
-          </div>
-        {% endif %}
-      {% endwith %}
-    </div>
-  {% empty %}
-    <div class="alert alert-info" role="alert">
-      <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
-      {% if perms.extras.add_scriptmodule %}
-        {% url 'extras:scriptmodule_add' as create_script_url %}
-        {% blocktrans trimmed %}
-          Get started by <a href="{{ create_script_url }}">creating a script</a> from an uploaded file or data source.
-        {% endblocktrans %}
-      {% endif %}
-    </div>
-  {% endfor %}
+  {% include 'extras/inc/script_list_content.html' with embedded=False %}
 {% endblock content %}

+ 2 - 2
netbox/templates/extras/tableconfig_edit.html

@@ -36,10 +36,10 @@
       <div class="col-5 text-center">
         <label class="form-label">{{ form.columns.label }}</label>
         {{ form.columns }}
-        <a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
+        <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-up" data-target="columns">
           <i class="mdi mdi-arrow-up-bold"></i> {% trans "Move Up" %}
         </a>
-        <a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-down" data-target="id_columns">
+        <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-down" data-target="columns">
           <i class="mdi mdi-arrow-down-bold"></i> {% trans "Move Down" %}
         </a>
       </div>

+ 17 - 0
netbox/templates/graphql/graphiql.html

@@ -88,6 +88,21 @@
 #
 `;
 
+      let sharedQuery;
+      const hashArgs = new URLSearchParams(window.location.hash.substring(1));
+      if (hashArgs.has('query')) {
+        sharedQuery = hashArgs.get('query');
+        // reset url to not motivate copying of stale URL
+        hashArgs.delete('query');
+        let remainingHash = "";
+        if (hashArgs.size !== 0) {
+          remainingHash = `#${hashArgs.toString()}`;
+        }
+        history.pushState("", document.title,
+          window.location.pathname + window.location.search + remainingHash
+        );
+      }
+
       const fetchURL = window.location.href;
 
       function httpUrlToWebSockeUrl(url) {
@@ -123,6 +138,8 @@
           defaultEditorToolsVisibility: true,
           plugins: [explorerPlugin],
           inputValueDeprecation: true,
+          defaultQuery: EXAMPLE_QUERY,
+          query: sharedQuery,
         }),
       );
     </script>

+ 2 - 2
netbox/templates/htmx/notifications.html

@@ -15,14 +15,14 @@
           <div class="d-block text-secondary fs-5">{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}</div>
         </div>
         <div class="col-auto">
-          <a href="#" hx-get="{% url 'extras:notification_dismiss' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-secondary" title="{% trans "Dismiss" %}">
+          <a href="#" hx-get="{% url 'extras:notification_dismiss' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-red" title="{% trans "Dismiss" %}">
             <i class="mdi mdi-close"></i>
           </a>
         </div>
       </div>
     </div>
   {% empty %}
-    <div class="dropdown-item text-muted">
+    <div class="dropdown-item disabled">
       {% trans "No unread notifications" %}
     </div>
   {% endfor %}

+ 2 - 2
netbox/templates/inc/light_toggle.html

@@ -1,10 +1,10 @@
 {% load i18n %}
 
 <div class="d-flex ms-2">
-  <button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
+  <button class="nav-link color-mode-toggle hide-theme-dark fs-2 p-0 text-secondary" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
     <i class="mdi mdi-lightbulb"></i>
   </button>
-  <button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
+  <button class="nav-link color-mode-toggle hide-theme-light fs-2 p-0 text-secondary" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
     <i class="mdi mdi-lightbulb-on"></i>
   </button>
 </div>

+ 2 - 1
netbox/templates/inc/notification_bell.html

@@ -1,6 +1,7 @@
 {% if notifications %}
   <span class="text-primary" id="notifications-alert" hx-swap-oob="true">
-    <i class="mdi mdi-bell-badge"></i>
+    <i class="mdi mdi-bell-ring"></i>
+    <span class="badge bg-red"></span>
   </span>
 {% else %}
   <span class="text-muted" id="notifications-alert" hx-swap-oob="true">

+ 2 - 2
netbox/templates/inc/user_menu.html

@@ -12,9 +12,9 @@
   {# Notifications #}
   {% with notifications=request.user.notifications.unread.exists %}
     <div class="dropdown">
-      <a href="#" class="nav-link px-1" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="{% trans "Notifications" %}">
+      <button class="nav-link fs-2 p-0" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="{% trans "Notifications" %}">
         {% include 'inc/notification_bell.html' %}
-      </a>
+      </button>
       <div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>
     </div>
   {% endwith %}

+ 2 - 2
netbox/templates/ipam/inc/panels/fhrp_groups.html

@@ -40,12 +40,12 @@
           <td>{{ assignment.priority }}</td>
           <td class="text-end d-print-none">
             {% if perms.ipam.change_fhrpgroupassignment %}
-              <a href="{% url 'ipam:fhrpgroupassignment_edit' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning lh-1" title="{% trans "Edit" %}">
+              <a href="{% url 'ipam:fhrpgroupassignment_edit' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning" title="{% trans "Edit" %}">
                 <i class="mdi mdi-pencil" aria-hidden="true"></i>
               </a>
             {% endif %}
             {% if perms.ipam.delete_fhrpgroupassignment %}
-              <a href="{% url 'ipam:fhrpgroupassignment_delete' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger lh-1" title="{% trans "Delete" %}">
+              <a href="{% url 'ipam:fhrpgroupassignment_delete' pk=assignment.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger" title="{% trans "Delete" %}">
                 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
               </a>
             {% endif %}

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


File diff suppressed because it is too large
+ 166 - 164
netbox/translations/cs/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/da/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/de/LC_MESSAGES/django.po


File diff suppressed because it is too large
+ 169 - 167
netbox/translations/en/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/es/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 169 - 168
netbox/translations/fr/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/it/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 168 - 167
netbox/translations/ja/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/nl/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/pl/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/pt/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/ru/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 168 - 167
netbox/translations/tr/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 165 - 164
netbox/translations/uk/LC_MESSAGES/django.po


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


File diff suppressed because it is too large
+ 168 - 167
netbox/translations/zh/LC_MESSAGES/django.po


+ 12 - 3
netbox/users/forms/model_forms.py

@@ -15,7 +15,7 @@ from users.models import *
 from utilities.data import flatten_dict
 from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
-from utilities.forms.widgets import DateTimePicker
+from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
 from utilities.permissions import qs_filter_from_constraints
 
 __all__ = (
@@ -272,12 +272,21 @@ class GroupForm(forms.ModelForm):
         return instance
 
 
+def get_object_types_choices():
+    return [
+        (ot.pk, str(ot))
+        for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
+    ]
+
+
 class ObjectPermissionForm(forms.ModelForm):
     object_types = ContentTypeMultipleChoiceField(
         label=_('Object types'),
         queryset=ObjectType.objects.all(),
-        limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
-        widget=forms.SelectMultiple(attrs={'size': 6})
+        widget=SplitMultiSelectWidget(
+            choices=get_object_types_choices
+        ),
+        help_text=_('Select the types of objects to which the permission will appy.')
     )
     can_view = forms.BooleanField(
         required=False

+ 1 - 1
netbox/users/tests/test_views.py

@@ -180,7 +180,7 @@ class ObjectPermissionTestCase(
         cls.form_data = {
             'name': 'Permission X',
             'description': 'A new permission',
-            'object_types': [object_type.pk],
+            'object_types_1': [object_type.pk],  # SplitMultiSelectWidget requires _1 suffix on field name
             'actions': 'view,edit,delete',
         }
 

+ 1 - 2
netbox/utilities/api.py

@@ -57,8 +57,7 @@ def is_api_request(request):
     """
     Return True of the request is being made via the REST API.
     """
-    api_path = reverse('api-root')
-    return request.path_info.startswith(api_path) and request.content_type == HTTP_CONTENT_TYPE_JSON
+    return request.path_info.startswith(reverse('api-root'))
 
 
 def is_graphql_request(request):

+ 11 - 3
netbox/utilities/data.py

@@ -160,9 +160,17 @@ def string_to_ranges(value):
         return None
     value.replace(' ', '')  # Remove whitespace
     values = []
-    for dash_range in value.split(','):
-        if '-' not in dash_range:
+    for data in value.split(','):
+        dash_range = data.strip().split('-')
+        if len(dash_range) == 1 and str(dash_range[0]).isdigit():
+            # Single integer value; expand to a range
+            lower = dash_range[0]
+            upper = dash_range[0]
+        elif len(dash_range) == 2 and str(dash_range[0]).isdigit() and str(dash_range[1]).isdigit():
+            # The range has two values and both are valid integers
+            lower = dash_range[0]
+            upper = dash_range[1]
+        else:
             return None
-        lower, upper = dash_range.split('-')
         values.append(NumericRange(int(lower), int(upper), bounds='[]'))
     return values

+ 4 - 2
netbox/utilities/forms/fields/array.py

@@ -32,12 +32,14 @@ class NumericArrayField(SimpleArrayField):
 class NumericRangeArrayField(forms.CharField):
     """
     A field which allows for array of numeric ranges:
-      Example: 1-5,7-20,30-50
+      Example: 1-5,10,20-30
     """
     def __init__(self, *args, help_text='', **kwargs):
         if not help_text:
             help_text = mark_safe(
-                _("Specify one or more numeric ranges separated by commas. Example: " + "<code>1-5,20-30</code>")
+                _(
+                    "Specify one or more individual numbers or numeric ranges separated by commas. Example: {example}"
+                ).format(example="<code>1-5,10,20-30</code>")
             )
         super().__init__(*args, help_text=help_text, **kwargs)
 

+ 77 - 0
netbox/utilities/forms/widgets/select.py

@@ -8,6 +8,7 @@ __all__ = (
     'ColorSelect',
     'HTMXSelect',
     'SelectWithPK',
+    'SplitMultiSelectWidget',
 )
 
 
@@ -63,3 +64,79 @@ class SelectWithPK(forms.Select):
     Include the primary key of each option in the option label (e.g. "Router7 (4721)").
     """
     option_template_name = 'widgets/select_option_with_pk.html'
+
+
+class AvailableOptions(forms.SelectMultiple):
+    """
+    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
+    will be empty.) Employed by SplitMultiSelectWidget.
+    """
+    def optgroups(self, name, value, attrs=None):
+        self.choices = [
+            choice for choice in self.choices if str(choice[0]) not in value
+        ]
+        value = []  # Clear selected choices
+        return super().optgroups(name, value, attrs)
+
+    def get_context(self, name, value, attrs):
+        context = super().get_context(name, value, attrs)
+
+        # This widget should never require a selection
+        context['widget']['attrs']['required'] = False
+
+        return context
+
+
+class SelectedOptions(forms.SelectMultiple):
+    """
+    Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
+    will include _all_ choices.) Employed by SplitMultiSelectWidget.
+    """
+    def optgroups(self, name, value, attrs=None):
+        self.choices = [
+            choice for choice in self.choices if str(choice[0]) in value
+        ]
+        value = []  # Clear selected choices
+        return super().optgroups(name, value, attrs)
+
+
+class SplitMultiSelectWidget(forms.MultiWidget):
+    """
+    Renders two <select multiple=true> widgets side-by-side: one listing available choices, the other listing selected
+    choices. Options are selected by moving them from the left column to the right.
+
+    Args:
+        ordering: If true, the selected choices list will include controls to reorder items within the list. This should
+                  be enabled only if the order of the selected choices is significant.
+    """
+    template_name = 'widgets/splitmultiselect.html'
+
+    def __init__(self, choices, attrs=None, ordering=False):
+        widgets = [
+            AvailableOptions(
+                attrs={'size': 8},
+                choices=choices
+            ),
+            SelectedOptions(
+                attrs={'size': 8, 'class': 'select-all'},
+                choices=choices
+            ),
+        ]
+
+        super().__init__(widgets, attrs)
+
+        self.ordering = ordering
+
+    def get_context(self, name, value, attrs):
+        # Replicate value for each multi-select widget
+        # Django bug? See django/forms/widgets.py L985
+        value = [value, value]
+
+        # Include ordering boolean in widget context
+        context = super().get_context(name, value, attrs)
+        context['widget']['ordering'] = self.ordering
+        return context
+
+    def value_from_datadict(self, data, files, name):
+        # Return only the choices from the SelectedOptions widget
+        return super().value_from_datadict(data, files, name)[1]

+ 34 - 0
netbox/utilities/prefetch.py

@@ -0,0 +1,34 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db.models import ManyToManyField
+from django.db.models.fields.related import ForeignObjectRel
+from taggit.managers import TaggableManager
+
+__all__ = (
+    'get_prefetchable_fields',
+)
+
+
+def get_prefetchable_fields(model):
+    """
+    Return a list containing the names of all fields on the given model which support prefetching.
+    """
+    field_names = []
+
+    for field in model._meta.get_fields():
+        # Forward relations (e.g. ManyToManyFields)
+        if isinstance(field, ManyToManyField):
+            field_names.append(field.name)
+
+        # Reverse relations (e.g. reverse ForeignKeys, reverse M2M)
+        elif isinstance(field, ForeignObjectRel):
+            field_names.append(field.get_accessor_name())
+
+        # Generic relations
+        elif isinstance(field, GenericRelation):
+            field_names.append(field.name)
+
+        # Tags
+        elif isinstance(field, TaggableManager):
+            field_names.append(field.name)
+
+    return field_names

+ 4 - 4
netbox/utilities/templates/helpers/table_config_form.html

@@ -27,11 +27,11 @@
           <div class="col-5 text-center">
             {{ form.columns.label }}
             {{ form.columns }}
-            <a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
-                <i class="mdi mdi-arrow-up-bold"></i> {% trans "Move Up" %}
+            <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-up" data-target="columns">
+              <i class="mdi mdi-arrow-up-bold"></i> {% trans "Move Up" %}
             </a>
-            <a tabindex="0" class="btn btn-primary btn-sm mt-2" id="move-option-down" data-target="id_columns">
-                <i class="mdi mdi-arrow-down-bold"></i> {% trans "Move Down" %}
+            <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-down" data-target="columns">
+              <i class="mdi mdi-arrow-down-bold"></i> {% trans "Move Down" %}
             </a>
           </div>
         </div>

+ 31 - 0
netbox/utilities/templates/widgets/splitmultiselect.html

@@ -0,0 +1,31 @@
+{% load i18n %}
+<div class="field-group">
+  <div class="row">
+    <div class="col-5 text-center">
+      <label class="form-label mb-1">{% trans "Available" %}</label>
+      {% include "django/forms/widgets/select.html" with widget=widget.subwidgets.0 %}
+    </div>
+    <div class="col-2 d-flex align-items-center">
+      <div>
+        <a tabindex="0" class="btn btn-success btn-sm w-100 my-2 move-option" data-source="{{ widget.name }}_0" data-target="{{ widget.name }}_1">
+          <i class="mdi mdi-arrow-right-bold"></i> {% trans "Add" %}
+        </a>
+        <a tabindex="0" class="btn btn-danger btn-sm w-100 my-2 move-option" data-source="{{ widget.name }}_1" data-target="{{ widget.name }}_0">
+          <i class="mdi mdi-arrow-left-bold"></i> {% trans "Remove" %}
+        </a>
+      </div>
+    </div>
+    <div class="col-5 text-center">
+      <label class="form-label mb-1">{% trans "Selected" %}</label>
+      {% include "django/forms/widgets/select.html" with widget=widget.subwidgets.1 %}
+      {% if widget.ordering %}
+        <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-up" data-target="{{ widget.name }}_1">
+          <i class="mdi mdi-arrow-up-bold"></i> {% trans "Move Up" %}
+        </a>
+        <a tabindex="0" class="btn btn-primary btn-sm mt-2 move-option-down" data-target="{{ widget.name }}_1">
+          <i class="mdi mdi-arrow-down-bold"></i> {% trans "Move Down" %}
+        </a>
+      {% endif %}
+    </div>
+  </div>
+</div>

+ 3 - 0
netbox/utilities/testing/api.py

@@ -550,6 +550,9 @@ class APIViewTestCases:
                 elif type(field.type) is StrawberryOptional and type(field.type.of_type) is LazyType:
                     fields_string += f'{field.name} {{ id }}\n'
                 elif hasattr(field, 'is_relation') and field.is_relation:
+                    # Ignore private fields
+                    if field.name.startswith('_'):
+                        continue
                     # Note: StrawberryField types do not have is_relation
                     fields_string += f'{field.name} {{ id }}\n'
                 elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):

+ 14 - 0
netbox/utilities/tests/test_data.py

@@ -66,3 +66,17 @@ class RangeFunctionsTestCase(TestCase):
                 NumericRange(100, 199, bounds='[]'),  # 100-199
             ]
         )
+
+        self.assertEqual(
+            string_to_ranges('1-2, 5, 10-12'),
+            [
+                NumericRange(1, 2, bounds='[]'),    # 1-2
+                NumericRange(5, 5, bounds='[]'),    # 5-5
+                NumericRange(10, 12, bounds='[]'),  # 10-12
+            ]
+        )
+
+        self.assertEqual(
+            string_to_ranges('2-10, a-b'),
+            None  # Fails to convert
+        )

Some files were not shown because too many files changed in this diff