Browse Source

Merge branch 'main' into feature

Jeremy Stretch 6 months ago
parent
commit
40dd36812c
68 changed files with 3643 additions and 2828 deletions
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 4 3
      base_requirements.txt
  4. 2 2
      docs/administration/replicating-netbox.md
  5. 0 7
      docs/installation/3-netbox.md
  6. 8 0
      docs/models/extras/configtemplate.md
  7. 8 0
      docs/models/extras/exporttemplate.md
  8. 14 12
      docs/reference/filtering.md
  9. 16 0
      docs/release-notes/version-4.3.md
  10. 8 11
      netbox/core/api/views.py
  11. 11 0
      netbox/core/jobs.py
  12. 1 6
      netbox/core/views.py
  13. 63 3
      netbox/dcim/forms/bulk_import.py
  14. 30 1
      netbox/dcim/svg/racks.py
  15. 23 11
      netbox/dcim/tests/test_views.py
  16. 3 1
      netbox/extras/api/views.py
  17. 6 0
      netbox/extras/constants.py
  18. 15 4
      netbox/extras/models/mixins.py
  19. 3 0
      netbox/extras/models/tags.py
  20. 4 0
      netbox/netbox/api/exceptions.py
  21. 7 0
      netbox/netbox/api/pagination.py
  22. 42 0
      netbox/netbox/tests/test_api.py
  23. 0 0
      netbox/project-static/dist/netbox.js
  24. 0 0
      netbox/project-static/dist/netbox.js.map
  25. 2 2
      netbox/project-static/src/racks.ts
  26. 262 252
      netbox/project-static/yarn.lock
  27. 2 2
      netbox/release.yaml
  28. 3 3
      netbox/templates/base/layout.html
  29. 1 1
      netbox/templates/dcim/inc/rack_elevation.html
  30. 7 2
      netbox/tenancy/forms/bulk_edit.py
  31. 1 0
      netbox/tenancy/tests/test_views.py
  32. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  33. 208 172
      netbox/translations/cs/LC_MESSAGES/django.po
  34. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  35. 206 170
      netbox/translations/da/LC_MESSAGES/django.po
  36. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  37. 208 172
      netbox/translations/de/LC_MESSAGES/django.po
  38. 126 104
      netbox/translations/en/LC_MESSAGES/django.po
  39. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  40. 208 170
      netbox/translations/es/LC_MESSAGES/django.po
  41. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  42. 208 170
      netbox/translations/fr/LC_MESSAGES/django.po
  43. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  44. 208 170
      netbox/translations/it/LC_MESSAGES/django.po
  45. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  46. 204 170
      netbox/translations/ja/LC_MESSAGES/django.po
  47. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  48. 208 170
      netbox/translations/nl/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  50. 208 172
      netbox/translations/pl/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  52. 208 170
      netbox/translations/pt/LC_MESSAGES/django.po
  53. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  54. 208 170
      netbox/translations/ru/LC_MESSAGES/django.po
  55. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  56. 210 174
      netbox/translations/tr/LC_MESSAGES/django.po
  57. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  58. 208 170
      netbox/translations/uk/LC_MESSAGES/django.po
  59. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  60. 205 170
      netbox/translations/zh/LC_MESSAGES/django.po
  61. 17 0
      netbox/users/migrations/0010_add_token_meta_ordering.py
  62. 1 1
      netbox/users/migrations/0011_concrete_objecttype.py
  63. 1 0
      netbox/users/models/tokens.py
  64. 2 0
      netbox/utilities/constants.py
  65. 3 0
      netbox/utilities/query.py
  66. 32 0
      netbox/utilities/tests/test_filters.py
  67. 1 1
      pyproject.toml
  68. 7 7
      requirements.txt

+ 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.4
+      placeholder: v4.3.5
     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.4
+      placeholder: v4.3.5
     validations:
       required: true
   - type: dropdown

+ 4 - 3
base_requirements.txt

@@ -8,7 +8,9 @@ django-cors-headers
 
 # Runtime UI tool for debugging Django
 # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
-django-debug-toolbar
+# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'" 
+# see https://github.com/netbox-community/netbox/issues/19974
+django-debug-toolbar==5.2.0
 
 # Library for writing reusable URL query filters
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -145,8 +147,7 @@ strawberry-graphql
 
 # Strawberry GraphQL Django extension
 # https://github.com/strawberry-graphql/strawberry-django/releases
-# See #19771
-strawberry-graphql-django==0.60.0
+strawberry-graphql-django
 
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

+ 2 - 2
docs/administration/replicating-netbox.md

@@ -18,10 +18,10 @@ pg_dump --username netbox --password --host localhost netbox > netbox.sql
 !!! note
     You may need to change the username, host, and/or database in the command above to match your installation.
 
-When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
+When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
 
 ```no-highlight
-pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
+pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
 ```
 
 ### Load an Exported Database

+ 0 - 7
docs/installation/3-netbox.md

@@ -290,13 +290,6 @@ Quit the server with CONTROL-C.
 
 Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
 
-!!! note
-    By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
-
-    ```no-highlight
-    firewall-cmd --zone=public --add-port=8000/tcp
-    ```
-
 !!! danger "Not for production use"
     The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
 

+ 8 - 0
docs/models/extras/configtemplate.md

@@ -24,6 +24,14 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
 
 A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
 
+The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
+
+```json
+{
+    "undefined": "jinja2.StrictUndefined"
+}
+```
+
 ### MIME Type
 
 !!! info "This field was introduced in NetBox v4.3."

+ 8 - 0
docs/models/extras/exporttemplate.md

@@ -26,6 +26,14 @@ Jinja2 template code for rendering the exported data.
 
 A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
 
+The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
+
+```json
+{
+    "undefined": "jinja2.StrictUndefined"
+}
+```
+
 ### MIME Type
 
 The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.

+ 14 - 12
docs/reference/filtering.md

@@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
 
 String based (char) fields (Name, Address, etc) support these lookup expressions:
 
-| Filter  | Description                            |
-|---------|----------------------------------------|
-| `n`     | Not equal to                           |
-| `ic`    | Contains (case-insensitive)            |
-| `nic`   | Does not contain (case-insensitive)    |
-| `isw`   | Starts with (case-insensitive)         |
-| `nisw`  | Does not start with (case-insensitive) |
-| `iew`   | Ends with (case-insensitive)           |
-| `niew`  | Does not end with (case-insensitive)   |
-| `ie`    | Exact match (case-insensitive)         |
-| `nie`   | Inverse exact match (case-insensitive) |
-| `empty` | Is empty/null (boolean)                |
+| Filter   | Description                            |
+|----------|----------------------------------------|
+| `n`      | Not equal to                           |
+| `ic`     | Contains (case-insensitive)            |
+| `nic`    | Does not contain (case-insensitive)    |
+| `isw`    | Starts with (case-insensitive)         |
+| `nisw`   | Does not start with (case-insensitive) |
+| `iew`    | Ends with (case-insensitive)           |
+| `niew`   | Does not end with (case-insensitive)   |
+| `ie`     | Exact match (case-insensitive)         |
+| `nie`    | Inverse exact match (case-insensitive) |
+| `empty`  | Is empty/null (boolean)                |
+| `regex`  | Regexp matching                        |
+| `iregex` | Regexp matching (case-insensitive)     |
 
 Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
 

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

@@ -1,5 +1,21 @@
 # NetBox v4.3
 
+## v4.3.5 (2025-07-29)
+
+### Enhancements
+* [#18797](https://github.com/netbox-community/netbox/issues/18797) - Added jinja2.StrictUndefined option for config template rendering to catch undefined variables
+* [#18936](https://github.com/netbox-community/netbox/issues/18936) - Cable imports now accept color names (e.g. "red", "blue") in addition to hex color codes
+* [#19840](https://github.com/netbox-community/netbox/issues/19840) - Cable imports now support specifying site information for better organization
+* [#19902](https://github.com/netbox-community/netbox/issues/19902) - Device names in rack elevation SVG exports are automatically truncated to prevent overflow beyond rack unit boundaries
+* [#19903](https://github.com/netbox-community/netbox/issues/19903) - String field filters now support `regex` and `iregex` lookups for advanced pattern matching
+* [#19910](https://github.com/netbox-community/netbox/issues/19910) - Internet-dependent links are no longer visible when running in air-gapped environments
+
+### Bug Fixes
+* [#18900](https://github.com/netbox-community/netbox/issues/18900) - REST API paginator now raises proper exceptions when attempting to paginate unordered querysets
+* [#19916](https://github.com/netbox-community/netbox/issues/19916) - Rack elevation image/label dropdown functionality restored
+* [#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
+
 ## v4.3.4 (2025-07-15)
 
 ### Enhancements

+ 8 - 11
netbox/core/api/views.py

@@ -1,30 +1,29 @@
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext_lazy as _
+from django_rq.queues import get_redis_connection
+from django_rq.settings import QUEUES_LIST
+from django_rq.utils import get_statistics
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema
+from rest_framework import viewsets
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import IsAdminUser
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ReadOnlyModelViewSet
+from rq.job import Job as RQ_Job
+from rq.worker import Worker
 
 from core import filtersets
-from core.choices import DataSourceStatusChoices
 from core.jobs import SyncDataSourceJob
 from core.models import *
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
-from django_rq.queues import get_redis_connection
-from django_rq.utils import get_statistics
-from django_rq.settings import QUEUES_LIST
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import LimitOffsetListPagination
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
-from rest_framework import viewsets
-from rest_framework.permissions import IsAdminUser
-from rq.job import Job as RQ_Job
-from rq.worker import Worker
 from . import serializers
 
 
@@ -51,10 +50,8 @@ class DataSourceViewSet(NetBoxModelViewSet):
         if not request.user.has_perm('core.sync_datasource', obj=datasource):
             raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
 
-        # Enqueue the sync job & update the DataSource's status
+        # Enqueue the sync job
         SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
-        datasource.status = DataSourceStatusChoices.QUEUED
-        DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
 
         serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
 

+ 11 - 0
netbox/core/jobs.py

@@ -28,6 +28,17 @@ class SyncDataSourceJob(JobRunner):
     class Meta:
         name = 'Synchronization'
 
+    @classmethod
+    def enqueue(cls, *args, **kwargs):
+        job = super().enqueue(*args, **kwargs)
+
+        # Update the DataSource's synchronization status to queued
+        if datasource := job.object:
+            datasource.status = DataSourceStatusChoices.QUEUED
+            DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
+
+        return job
+
     def run(self, *args, **kwargs):
         datasource = DataSource.objects.get(pk=self.job.object_id)
         self.logger.debug(f"Found DataSource ID {datasource.pk}")

+ 1 - 6
netbox/core/views.py

@@ -34,7 +34,6 @@ from utilities.json import ConfigJSONEncoder
 from utilities.query import count_related
 from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
-from .choices import DataSourceStatusChoices
 from .jobs import SyncDataSourceJob
 from .models import *
 from .plugins import get_catalog_plugins, get_local_plugins
@@ -79,12 +78,8 @@ class DataSourceSyncView(BaseObjectView):
 
     def post(self, request, pk):
         datasource = get_object_or_404(self.queryset, pk=pk)
-
-        # Enqueue the sync job & update the DataSource's status
+        # Enqueue the sync job
         job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
-        datasource.status = DataSourceStatusChoices.QUEUED
-        DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
-
         messages.success(
             request,
             _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)

+ 63 - 3
netbox/dcim/forms/bulk_import.py

@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
 
 class CableImportForm(NetBoxModelImportForm):
     # Termination A
+    side_a_site = CSVModelChoiceField(
+        label=_('Side A site'),
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Site of parent device A (if any)'),
+    )
     side_a_device = CSVModelChoiceField(
         label=_('Side A device'),
         queryset=Device.objects.all(),
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
     )
 
     # Termination B
+    side_b_site = CSVModelChoiceField(
+        label=_('Side B site'),
+        queryset=Site.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Site of parent device B (if any)'),
+    )
     side_b_device = CSVModelChoiceField(
         label=_('Side B device'),
         queryset=Device.objects.all(),
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
         required=False,
         help_text=_('Length unit')
     )
+    color = forms.CharField(
+        label=_('Color'),
+        required=False,
+        max_length=16,
+        help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
+    )
 
     class Meta:
         model = Cable
         fields = [
-            'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
-            'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
+            'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
+            'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
+            'comments', 'tags',
         ]
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit choices for side_a_device to the assigned side_a_site
+            if side_a_site := data.get('side_a_site'):
+                side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
+                self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
+                    **side_a_device_params
+                )
+
+            # Limit choices for side_b_device to the assigned side_b_site
+            if side_b_site := data.get('side_b_site'):
+                side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
+                self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
+                    **side_b_device_params
+                )
+
     def _clean_side(self, side):
         """
         Derive a Cable's A/B termination objects.
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
         setattr(self.instance, f'{side}_terminations', [termination_object])
         return termination_object
 
+    def _clean_color(self, color):
+        """
+        Derive a colors hex code
+
+        :param color: color as hex or color name
+        """
+        color_parsed = color.strip().lower()
+
+        for hex_code, label in ColorChoices.CHOICES:
+            if color.lower() == label.lower():
+                color_parsed = hex_code
+
+        if len(color_parsed) > 6:
+            raise forms.ValidationError(
+                _(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
+            )
+        return color_parsed
+
     def clean_side_a_name(self):
         return self._clean_side('a')
 
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
         length_unit = self.cleaned_data.get('length_unit', None)
         return length_unit if length_unit is not None else ''
 
-
+    def clean_color(self):
+        color = self.cleaned_data.get('color', None)
+        return self._clean_color(color) if color is not None else ''
 #
 # Virtual chassis
 #
 
+
 class VirtualChassisImportForm(NetBoxModelImportForm):
     master = CSVModelChoiceField(
         label=_('Master'),

+ 30 - 1
netbox/dcim/svg/racks.py

@@ -3,6 +3,7 @@ import svgwrite
 from svgwrite.container import Hyperlink
 from svgwrite.image import Image
 from svgwrite.gradients import LinearGradient
+from svgwrite.masking import ClipPath
 from svgwrite.shapes import Rect
 from svgwrite.text import Text
 
@@ -67,6 +68,20 @@ def get_device_description(device):
     return description
 
 
+def truncate_text(text, width, font_size=15):
+    """
+    Truncate text to fit within the width of a rectangle.
+
+    :param text: The text to truncate
+    :param width: Width of rectangle
+    :param font_size: Font size (default is 15, ~0.875rem)
+    """
+    char_width = font_size * 0.6  # 0.6 is an approximation of the average character width in pixels
+    max_char = int(width / char_width)
+
+    return text if len(text) <= max_char else text[:max_char] + '...'
+
+
 class RackElevationSVG:
     """
     Use this class to render a rack elevation as an SVG image.
@@ -177,12 +192,26 @@ class RackElevationSVG:
         link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
         link.set_desc(description)
 
+        # Create clipPath element
+        # This is necessary as fallback because the truncate_text method is an approximation
+        clip_id = f"clip-{device.id}"
+        clip_path = ClipPath(id=clip_id)
+        clip_path.add(Rect(coords, size))
+
+        self.drawing.defs.add(clip_path)
+
+        # Name to display
+        display_name = truncate_text(name, size[0])
+
         # Add rect element to hyperlink
         if color:
             link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
         else:
             link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
-        link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
+        link.add(
+            Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
+                 class_=f'label{css_extra}')
+        )
 
         # Embed device type image if provided
         if self.include_images and image:

+ 23 - 11
netbox/dcim/tests/test_views.py

@@ -3266,17 +3266,27 @@ class CableTestCase(
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         vc = VirtualChassis.objects.create(name='Virtual Chassis')
 
+        # NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
+        # different sites.
+        # The CSV test below demonstrates that devices with identical names on different sites can be created
+        # and referenced successfully.
         devices = (
-            Device(name='Device 1', site=site, device_type=devicetype, role=role),
-            Device(name='Device 2', site=site, device_type=devicetype, role=role),
-            Device(name='Device 3', site=site, device_type=devicetype, role=role),
-            Device(name='Device 4', site=site, device_type=devicetype, role=role),
+            # Create 'Device 1' assigned to 'Site 1'
+            Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
+            Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
+            Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
+            # Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
+            Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
         )
         Device.objects.bulk_create(devices)
 
@@ -3327,13 +3337,15 @@ class CableTestCase(
             'tags': [t.pk for t in tags],
         }
 
+        # Ensure that CSV bulk import supports assigning terminations from parent devices that share
+        # the same device name, provided those devices belong to different sites.
         cls.csv_data = (
-            "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
-            "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
-            "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
-            "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
-            "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
-            "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
+            "side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
+            "Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
+            "Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
+            "Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
+            "Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
+            "Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
         )
 
         cls.csv_update_data = (

+ 3 - 1
netbox/extras/api/views.py

@@ -184,7 +184,9 @@ class TagViewSet(NetBoxModelViewSet):
 
 
 class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
-    queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
+    queryset = TaggedItem.objects.prefetch_related(
+        'content_type', 'content_object', 'tag'
+    ).order_by('tag__weight', 'tag__name')
     serializer_class = serializers.TaggedItemSerializer
     filterset_class = filtersets.TaggedItemFilterSet
 

+ 6 - 0
netbox/extras/constants.py

@@ -21,6 +21,12 @@ WEBHOOK_EVENT_TYPES = {
     JOB_ERRORED: 'job_ended',
 }
 
+# Jinja environment parameters which support path imports
+JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
+    'undefined',
+    'finalize',
+)
+
 # Dashboard
 DEFAULT_DASHBOARD = [
     {

+ 15 - 4
netbox/extras/models/mixins.py

@@ -2,16 +2,17 @@ import importlib.abc
 import importlib.util
 import os
 import sys
+
 from django.core.files.storage import storages
 from django.db import models
-from django.utils.translation import gettext_lazy as _
 from django.http import HttpResponse
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
 
-from extras.constants import DEFAULT_MIME_TYPE
+from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
 from extras.utils import filename_from_model, filename_from_object
 from utilities.jinja2 import render_jinja2
 
-
 __all__ = (
     'PythonModuleMixin',
     'RenderTemplateMixin',
@@ -125,12 +126,22 @@ class RenderTemplateMixin(models.Model):
             class_name=self.__class__
         ))
 
+    def get_environment_params(self):
+        """
+        Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
+        """
+        params = self.environment_params or {}
+        for name, value in params.items():
+            if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
+                params[name] = import_string(value)
+        return params
+
     def render(self, context=None, queryset=None):
         """
         Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
         """
         context = self.get_context(context=context, queryset=queryset)
-        env_params = self.environment_params or {}
+        env_params = self.get_environment_params()
         output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
 
         # Replace CRLF-style line terminators

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

@@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase):
         indexes = [models.Index(fields=["content_type", "object_id"])]
         verbose_name = _('tagged item')
         verbose_name_plural = _('tagged items')
+        # Note: while there is no ordering applied here (because it would basically be done on fields
+        # of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
+        # to allow for proper pagination.

+ 4 - 0
netbox/netbox/api/exceptions.py

@@ -12,3 +12,7 @@ class SerializerNotFound(Exception):
 
 class GraphQLTypeNotFound(Exception):
     pass
+
+
+class QuerySetNotOrdered(Exception):
+    pass

+ 7 - 0
netbox/netbox/api/pagination.py

@@ -1,6 +1,7 @@
 from django.db.models import QuerySet
 from rest_framework.pagination import LimitOffsetPagination
 
+from netbox.api.exceptions import QuerySetNotOrdered
 from netbox.config import get_config
 
 
@@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
 
     def paginate_queryset(self, queryset, request, view=None):
 
+        if isinstance(queryset, QuerySet) and not queryset.ordered:
+            raise QuerySetNotOrdered(
+                "Paginating over an unordered queryset is unreliable. Ensure that a minimal "
+                "ordering has been applied to the queryset for this API endpoint."
+            )
+
         if isinstance(queryset, QuerySet):
             self.count = self.get_queryset_count(queryset)
         else:

+ 42 - 0
netbox/netbox/tests/test_api.py

@@ -1,8 +1,13 @@
 import uuid
 
+from django.test import RequestFactory, TestCase
 from django.urls import reverse
+from rest_framework.request import Request
 
+from netbox.api.exceptions import QuerySetNotOrdered
+from netbox.api.pagination import OptionalLimitOffsetPagination
 from utilities.testing import APITestCase
+from users.models import Token
 
 
 class AppTest(APITestCase):
@@ -26,3 +31,40 @@ class AppTest(APITestCase):
         response = self.client.get(f'{url}?format=api', **self.header)
 
         self.assertEqual(response.status_code, 200)
+
+
+class OptionalLimitOffsetPaginationTest(TestCase):
+
+    def setUp(self):
+        self.paginator = OptionalLimitOffsetPagination()
+        self.factory = RequestFactory()
+
+    def _make_drf_request(self, path='/', query_params=None):
+        """Helper to create a proper DRF Request object"""
+        return Request(self.factory.get(path, query_params or {}))
+
+    def test_raises_exception_for_unordered_queryset(self):
+        """Should raise QuerySetNotOrdered for unordered QuerySet"""
+        queryset = Token.objects.all().order_by()
+        request = self._make_drf_request()
+
+        with self.assertRaises(QuerySetNotOrdered) as cm:
+            self.paginator.paginate_queryset(queryset, request)
+
+        error_msg = str(cm.exception)
+        self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
+        self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
+
+    def test_allows_ordered_queryset(self):
+        """Should not raise exception for ordered QuerySet"""
+        queryset = Token.objects.all().order_by('created')
+        request = self._make_drf_request()
+
+        self.paginator.paginate_queryset(queryset, request)  # Should not raise exception
+
+    def test_allows_non_queryset_iterables(self):
+        """Should not raise exception for non-QuerySet iterables"""
+        iterable = [1, 2, 3, 4, 5]
+        request = self._make_drf_request()
+
+        self.paginator.paginate_queryset(iterable, request)  # Should not raise exception

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


+ 2 - 2
netbox/project-static/src/racks.ts

@@ -35,7 +35,7 @@ function showRackElements(
   selector: string,
   elevation: HTMLObjectElement,
 ): void {
-  const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
+  const elements = elevation.querySelectorAll(selector) ?? [];
   for (const element of elements) {
     element.classList.remove('hidden');
   }
@@ -45,7 +45,7 @@ function hideRackElements(
   selector: string,
   elevation: HTMLObjectElement,
 ): void {
-  const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
+  const elements = elevation.querySelectorAll(selector) ?? [];
   for (const element of elements) {
     element.classList.add('hidden');
   }

+ 262 - 252
netbox/project-static/yarn.lock

@@ -19,135 +19,135 @@
   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
   integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
 
-"@esbuild/aix-ppc64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz#164b19122e2ed54f85469df9dea98ddb01d5e79e"
-  integrity sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==
-
-"@esbuild/android-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz#8f539e7def848f764f6432598e51cc3820fde3a5"
-  integrity sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==
-
-"@esbuild/android-arm@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.6.tgz#4ceb0f40113e9861169be83e2a670c260dd234ff"
-  integrity sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==
-
-"@esbuild/android-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.6.tgz#ad4f280057622c25fe985c08999443a195dc63a8"
-  integrity sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==
-
-"@esbuild/darwin-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz#d1f04027396b3d6afc96bacd0d13167dfd9f01f7"
-  integrity sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==
-
-"@esbuild/darwin-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz#2b4a6cedb799f635758d7832d75b23772c8ef68f"
-  integrity sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==
-
-"@esbuild/freebsd-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz#a26266cc97dd78dc3c3f3d6788b1b83697b1055d"
-  integrity sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==
-
-"@esbuild/freebsd-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz#9feb8e826735c568ebfd94859b22a3fbb6a9bdd2"
-  integrity sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==
-
-"@esbuild/linux-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz#c07cbed8e249f4c28e7f32781d36fc4695293d28"
-  integrity sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==
-
-"@esbuild/linux-arm@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz#d6e2cd8ef3196468065d41f13fa2a61aaa72644a"
-  integrity sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==
-
-"@esbuild/linux-ia32@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz#3e682bd47c4eddcc4b8f1393dfc8222482f17997"
-  integrity sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==
-
-"@esbuild/linux-loong64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz#473f5ea2e52399c08ad4cd6b12e6dbcddd630f05"
-  integrity sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==
-
-"@esbuild/linux-mips64el@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz#9960631c9fd61605b0939c19043acf4ef2b51718"
-  integrity sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==
-
-"@esbuild/linux-ppc64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz#477cbf8bb04aa034b94f362c32c86b5c31db8d3e"
-  integrity sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==
-
-"@esbuild/linux-riscv64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz#bcdb46c8fb8e93aa779e9a0a62cd4ac00dcac626"
-  integrity sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==
-
-"@esbuild/linux-s390x@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz#f412cf5fdf0aea849ff51c73fd817c6c0234d46d"
-  integrity sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==
-
-"@esbuild/linux-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz#d8233c09b5ebc0c855712dc5eeb835a3a3341108"
-  integrity sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==
-
-"@esbuild/netbsd-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz#f51ae8dd1474172e73cf9cbaf8a38d1c72dd8f1a"
-  integrity sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==
-
-"@esbuild/netbsd-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz#a267538602c0e50a858cf41dcfe5d8036f8da8e7"
-  integrity sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==
-
-"@esbuild/openbsd-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz#a51be60c425b85c216479b8c344ad0511635f2d2"
-  integrity sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==
-
-"@esbuild/openbsd-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz#7e4a743c73f75562e29223ba69d0be6c9c9008da"
-  integrity sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==
-
-"@esbuild/openharmony-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz#2087a5028f387879154ebf44bdedfafa17682e5b"
-  integrity sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==
-
-"@esbuild/sunos-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz#56531f861723ea0dc6283a2bb8837304223cb736"
-  integrity sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==
-
-"@esbuild/win32-arm64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz#f4989f033deac6fae323acff58764fa8bc01436e"
-  integrity sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==
-
-"@esbuild/win32-ia32@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz#b260e9df71e3939eb33925076d39f63cec7d1525"
-  integrity sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==
-
-"@esbuild/win32-x64@0.25.6":
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz#4276edd5c105bc28b11c6a1f76fb9d29d1bd25c1"
-  integrity sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==
+"@esbuild/aix-ppc64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727"
+  integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==
+
+"@esbuild/android-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6"
+  integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==
+
+"@esbuild/android-arm@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059"
+  integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==
+
+"@esbuild/android-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0"
+  integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==
+
+"@esbuild/darwin-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d"
+  integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==
+
+"@esbuild/darwin-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2"
+  integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==
+
+"@esbuild/freebsd-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada"
+  integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==
+
+"@esbuild/freebsd-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343"
+  integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==
+
+"@esbuild/linux-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9"
+  integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==
+
+"@esbuild/linux-arm@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19"
+  integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==
+
+"@esbuild/linux-ia32@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d"
+  integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==
+
+"@esbuild/linux-loong64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4"
+  integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==
+
+"@esbuild/linux-mips64el@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340"
+  integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==
+
+"@esbuild/linux-ppc64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe"
+  integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==
+
+"@esbuild/linux-riscv64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083"
+  integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==
+
+"@esbuild/linux-s390x@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71"
+  integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==
+
+"@esbuild/linux-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac"
+  integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==
+
+"@esbuild/netbsd-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0"
+  integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==
+
+"@esbuild/netbsd-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c"
+  integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==
+
+"@esbuild/openbsd-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45"
+  integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==
+
+"@esbuild/openbsd-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc"
+  integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==
+
+"@esbuild/openharmony-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a"
+  integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==
+
+"@esbuild/sunos-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb"
+  integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==
+
+"@esbuild/win32-arm64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1"
+  integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==
+
+"@esbuild/win32-ia32@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4"
+  integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==
+
+"@esbuild/win32-x64@0.25.8":
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c"
+  integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==
 
 "@eslint-community/eslint-utils@^4.2.0":
   version "4.4.0"
@@ -163,7 +163,12 @@
   dependencies:
     eslint-visitor-keys "^3.4.3"
 
-"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1":
+"@eslint-community/regexpp@^4.10.0":
+  version "4.12.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
+  integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
+
+"@eslint-community/regexpp@^4.6.1":
   version "4.11.0"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae"
   integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==
@@ -464,10 +469,10 @@
     "@parcel/watcher-win32-ia32" "2.4.1"
     "@parcel/watcher-win32-x64" "2.4.1"
 
-"@pkgr/core@^0.2.4":
-  version "0.2.7"
-  resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058"
-  integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==
+"@pkgr/core@^0.2.9":
+  version "0.2.9"
+  resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b"
+  integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
 
 "@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
   version "2.11.8"
@@ -840,78 +845,78 @@
     "@types/estree" "*"
 
 "@typescript-eslint/eslint-plugin@^8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz#332392883f936137cd6252c8eb236d298e514e70"
-  integrity sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==
+  version "8.38.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz#6e5220d16f2691ab6d983c1737dd5b36e17641b7"
+  integrity sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==
   dependencies:
     "@eslint-community/regexpp" "^4.10.0"
-    "@typescript-eslint/scope-manager" "8.37.0"
-    "@typescript-eslint/type-utils" "8.37.0"
-    "@typescript-eslint/utils" "8.37.0"
-    "@typescript-eslint/visitor-keys" "8.37.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"
     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.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.37.0.tgz#b87f6b61e25ad5cc5bbf8baf809b8da889c89804"
-  integrity sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==
-  dependencies:
-    "@typescript-eslint/scope-manager" "8.37.0"
-    "@typescript-eslint/types" "8.37.0"
-    "@typescript-eslint/typescript-estree" "8.37.0"
-    "@typescript-eslint/visitor-keys" "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"
     debug "^4.3.4"
 
-"@typescript-eslint/project-service@8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.37.0.tgz#0594352e32a4ac9258591b88af77b5653800cdfe"
-  integrity sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==
+"@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==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.37.0"
-    "@typescript-eslint/types" "^8.37.0"
+    "@typescript-eslint/tsconfig-utils" "^8.38.0"
+    "@typescript-eslint/types" "^8.38.0"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz#a31a3c80ca2ef4ed58de13742debb692e7d4c0a4"
-  integrity sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==
+"@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==
   dependencies:
-    "@typescript-eslint/types" "8.37.0"
-    "@typescript-eslint/visitor-keys" "8.37.0"
+    "@typescript-eslint/types" "8.38.0"
+    "@typescript-eslint/visitor-keys" "8.38.0"
 
-"@typescript-eslint/tsconfig-utils@8.37.0", "@typescript-eslint/tsconfig-utils@^8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz#47a2760d265c6125f8e7864bc5c8537cad2bd053"
-  integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
+"@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/type-utils@8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz#2a682e4c6ff5886712dad57e9787b5e417124507"
-  integrity sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==
+"@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==
   dependencies:
-    "@typescript-eslint/types" "8.37.0"
-    "@typescript-eslint/typescript-estree" "8.37.0"
-    "@typescript-eslint/utils" "8.37.0"
+    "@typescript-eslint/types" "8.38.0"
+    "@typescript-eslint/typescript-estree" "8.38.0"
+    "@typescript-eslint/utils" "8.38.0"
     debug "^4.3.4"
     ts-api-utils "^2.1.0"
 
-"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.37.0.tgz#09517aa9625eb3c68941dde3ac8835740587b6ff"
-  integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
+"@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/typescript-estree@8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz#a07e4574d8e6e4355a558f61323730c987f5fcbc"
-  integrity sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==
+"@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==
   dependencies:
-    "@typescript-eslint/project-service" "8.37.0"
-    "@typescript-eslint/tsconfig-utils" "8.37.0"
-    "@typescript-eslint/types" "8.37.0"
-    "@typescript-eslint/visitor-keys" "8.37.0"
+    "@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"
     debug "^4.3.4"
     fast-glob "^3.3.2"
     is-glob "^4.0.3"
@@ -919,22 +924,22 @@
     semver "^7.6.0"
     ts-api-utils "^2.1.0"
 
-"@typescript-eslint/utils@8.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.37.0.tgz#189ea59b2709f5d898614611f091a776751ee335"
-  integrity sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==
+"@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==
   dependencies:
     "@eslint-community/eslint-utils" "^4.7.0"
-    "@typescript-eslint/scope-manager" "8.37.0"
-    "@typescript-eslint/types" "8.37.0"
-    "@typescript-eslint/typescript-estree" "8.37.0"
+    "@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.37.0":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz#cdb6a6bd3e8d6dd69bd70c1bdda36e2d18737455"
-  integrity sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==
+"@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==
   dependencies:
-    "@typescript-eslint/types" "8.37.0"
+    "@typescript-eslint/types" "8.38.0"
     eslint-visitor-keys "^4.2.1"
 
 "@ungap/structured-clone@^1.2.0":
@@ -1120,9 +1125,9 @@ brace-expansion@^1.1.7:
     concat-map "0.0.1"
 
 brace-expansion@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
-  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
+  integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
   dependencies:
     balanced-match "^1.0.0"
 
@@ -1344,11 +1349,11 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.3.5:
     ms "2.1.2"
 
 debug@^4.3.4:
-  version "4.3.4"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
-  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
+  integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
   dependencies:
-    ms "2.1.2"
+    ms "^2.1.3"
 
 decode-uri-component@^0.4.1:
   version "0.4.1"
@@ -1633,36 +1638,36 @@ esbuild-sass-plugin@^3.3.1:
     sass "^1.71.1"
 
 esbuild@^0.25.6:
-  version "0.25.6"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.6.tgz#9b82a3db2fa131aec069ab040fd57ed0a880cdcd"
-  integrity sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==
+  version "0.25.8"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"
+  integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.25.6"
-    "@esbuild/android-arm" "0.25.6"
-    "@esbuild/android-arm64" "0.25.6"
-    "@esbuild/android-x64" "0.25.6"
-    "@esbuild/darwin-arm64" "0.25.6"
-    "@esbuild/darwin-x64" "0.25.6"
-    "@esbuild/freebsd-arm64" "0.25.6"
-    "@esbuild/freebsd-x64" "0.25.6"
-    "@esbuild/linux-arm" "0.25.6"
-    "@esbuild/linux-arm64" "0.25.6"
-    "@esbuild/linux-ia32" "0.25.6"
-    "@esbuild/linux-loong64" "0.25.6"
-    "@esbuild/linux-mips64el" "0.25.6"
-    "@esbuild/linux-ppc64" "0.25.6"
-    "@esbuild/linux-riscv64" "0.25.6"
-    "@esbuild/linux-s390x" "0.25.6"
-    "@esbuild/linux-x64" "0.25.6"
-    "@esbuild/netbsd-arm64" "0.25.6"
-    "@esbuild/netbsd-x64" "0.25.6"
-    "@esbuild/openbsd-arm64" "0.25.6"
-    "@esbuild/openbsd-x64" "0.25.6"
-    "@esbuild/openharmony-arm64" "0.25.6"
-    "@esbuild/sunos-x64" "0.25.6"
-    "@esbuild/win32-arm64" "0.25.6"
-    "@esbuild/win32-ia32" "0.25.6"
-    "@esbuild/win32-x64" "0.25.6"
+    "@esbuild/aix-ppc64" "0.25.8"
+    "@esbuild/android-arm" "0.25.8"
+    "@esbuild/android-arm64" "0.25.8"
+    "@esbuild/android-x64" "0.25.8"
+    "@esbuild/darwin-arm64" "0.25.8"
+    "@esbuild/darwin-x64" "0.25.8"
+    "@esbuild/freebsd-arm64" "0.25.8"
+    "@esbuild/freebsd-x64" "0.25.8"
+    "@esbuild/linux-arm" "0.25.8"
+    "@esbuild/linux-arm64" "0.25.8"
+    "@esbuild/linux-ia32" "0.25.8"
+    "@esbuild/linux-loong64" "0.25.8"
+    "@esbuild/linux-mips64el" "0.25.8"
+    "@esbuild/linux-ppc64" "0.25.8"
+    "@esbuild/linux-riscv64" "0.25.8"
+    "@esbuild/linux-s390x" "0.25.8"
+    "@esbuild/linux-x64" "0.25.8"
+    "@esbuild/netbsd-arm64" "0.25.8"
+    "@esbuild/netbsd-x64" "0.25.8"
+    "@esbuild/openbsd-arm64" "0.25.8"
+    "@esbuild/openbsd-x64" "0.25.8"
+    "@esbuild/openharmony-arm64" "0.25.8"
+    "@esbuild/sunos-x64" "0.25.8"
+    "@esbuild/win32-arm64" "0.25.8"
+    "@esbuild/win32-ia32" "0.25.8"
+    "@esbuild/win32-x64" "0.25.8"
 
 escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -1737,9 +1742,9 @@ eslint-plugin-import@^2.32.0:
     tsconfig-paths "^3.15.0"
 
 eslint-plugin-prettier@^5.5.1:
-  version "5.5.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz#470820964de9aedb37e9ce62c3266d2d26d08d15"
-  integrity sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==
+  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==
   dependencies:
     prettier-linter-helpers "^1.0.0"
     synckit "^0.11.7"
@@ -1850,15 +1855,15 @@ fast-diff@^1.1.2:
   integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
 
 fast-glob@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
-  integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+  integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
     glob-parent "^5.1.2"
     merge2 "^1.3.0"
-    micromatch "^4.0.4"
+    micromatch "^4.0.8"
 
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
@@ -1871,9 +1876,9 @@ fast-levenshtein@^2.0.6:
   integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
 
 fastq@^1.6.0:
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
-  integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
+  integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
   dependencies:
     reusify "^1.0.4"
 
@@ -2741,7 +2746,7 @@ meros@^1.1.4:
   resolved "https://registry.yarnpkg.com/meros/-/meros-1.3.0.tgz#c617d2092739d55286bf618129280f362e6242f2"
   integrity sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==
 
-micromatch@^4.0.4, micromatch@^4.0.5:
+micromatch@^4.0.5, micromatch@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
   integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -2773,7 +2778,7 @@ ms@2.1.2:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-ms@^2.1.1:
+ms@^2.1.1, ms@^2.1.3:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@@ -3110,9 +3115,9 @@ resolve@^1.22.4, resolve@^1.22.8:
     supports-preserve-symlinks-flag "^1.0.0"
 
 reusify@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
-  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+  integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
 
 rimraf@^3.0.2:
   version "3.0.2"
@@ -3217,7 +3222,12 @@ semver@^6.3.1:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
   integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-semver@^7.6.0, semver@^7.6.3:
+semver@^7.6.0:
+  version "7.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
+  integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
+
+semver@^7.6.3:
   version "7.6.3"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
   integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
@@ -3430,11 +3440,11 @@ supports-preserve-symlinks-flag@^1.0.0:
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 synckit@^0.11.7:
-  version "0.11.8"
-  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.8.tgz#b2aaae998a4ef47ded60773ad06e7cb821f55457"
-  integrity sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==
+  version "0.11.11"
+  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0"
+  integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==
   dependencies:
-    "@pkgr/core" "^0.2.4"
+    "@pkgr/core" "^0.2.9"
 
 tapable@^2.2.0:
   version "2.2.1"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.3.4"
+version: "4.3.5"
 edition: "Community"
-published: "2025-07-15"
+published: "2025-07-29"

+ 3 - 3
netbox/templates/base/layout.html

@@ -55,7 +55,7 @@ Blocks:
           {# Release info #}
           <div class="text-muted text-center fs-5 my-3">
             {{ settings.RELEASE.name }}
-            {% if not settings.RELEASE.features.commercial %}
+            {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>
                 <a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> |
                 <a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a>
@@ -184,7 +184,7 @@ Blocks:
                 {% endif %}
 
                 {# Commercial links #}
-                {% if settings.RELEASE.features.commercial %}
+                {% if settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
                   {# LinkedIn #}
                   <li class="list-inline-item">
                     <a href="https://www.linkedin.com/company/netboxlabs/" target="_blank" class="link-secondary" rel="noopener" aria-label="LinkedIn">
@@ -199,7 +199,7 @@ Blocks:
                   </li>
 
                 {# Community links #}
-                {% else %}
+                {% elif not settings.ISOLATED_DEPLOYMENT %}
                   {# GitHub #}
                   <li class="list-inline-item">
                     <a href="https://github.com/netbox-community/netbox" target="_blank" class="link-secondary" rel="noopener" aria-label="{% trans "Source Code" %}">

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

@@ -1,5 +1,5 @@
 {% load i18n %}
-<div style="margin-left: -30px">
+<div style="margin-left: -30px" class="rack_elevation">
   <div
     hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
     hx-trigger="intersect"

+ 7 - 2
netbox/tenancy/forms/bulk_edit.py

@@ -45,12 +45,17 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm):
         queryset=TenantGroup.objects.all(),
         required=False
     )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
 
     model = Tenant
     fieldsets = (
-        FieldSet('group'),
+        FieldSet('group', 'description'),
     )
-    nullable_fields = ('group',)
+    nullable_fields = ('group', 'description')
 
 
 #

+ 1 - 0
netbox/tenancy/tests/test_views.py

@@ -98,6 +98,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.bulk_edit_data = {
             'group': tenant_groups[1].pk,
+            'description': 'Bulk edit description',
         }
 
 

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


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


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


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


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


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


+ 126 - 104
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-07-16 05:05+0000\n"
+"POT-Creation-Date: 2025-07-30 05:08+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -215,8 +215,8 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:344 netbox/dcim/forms/bulk_edit.py:730
 #: netbox/dcim/forms/bulk_edit.py:935 netbox/dcim/forms/bulk_import.py:134
 #: netbox/dcim/forms/bulk_import.py:236 netbox/dcim/forms/bulk_import.py:337
-#: netbox/dcim/forms/bulk_import.py:598 netbox/dcim/forms/bulk_import.py:1479
-#: netbox/dcim/forms/bulk_import.py:1507 netbox/dcim/forms/filtersets.py:89
+#: netbox/dcim/forms/bulk_import.py:598 netbox/dcim/forms/bulk_import.py:1539
+#: netbox/dcim/forms/bulk_import.py:1567 netbox/dcim/forms/filtersets.py:89
 #: netbox/dcim/forms/filtersets.py:227 netbox/dcim/forms/filtersets.py:344
 #: netbox/dcim/forms/filtersets.py:441 netbox/dcim/forms/filtersets.py:773
 #: netbox/dcim/forms/filtersets.py:992 netbox/dcim/forms/filtersets.py:1065
@@ -587,9 +587,10 @@ msgstr ""
 #: netbox/templates/wireless/wirelesslan.html:34
 #: netbox/templates/wireless/wirelesslangroup.html:33
 #: netbox/templates/wireless/wirelesslink.html:34
-#: netbox/tenancy/forms/bulk_edit.py:32 netbox/tenancy/forms/bulk_edit.py:82
-#: netbox/tenancy/forms/bulk_edit.py:130 netbox/users/forms/bulk_edit.py:64
-#: netbox/users/forms/bulk_edit.py:82 netbox/users/forms/bulk_edit.py:112
+#: netbox/tenancy/forms/bulk_edit.py:32 netbox/tenancy/forms/bulk_edit.py:49
+#: netbox/tenancy/forms/bulk_edit.py:87 netbox/tenancy/forms/bulk_edit.py:135
+#: netbox/users/forms/bulk_edit.py:64 netbox/users/forms/bulk_edit.py:82
+#: netbox/users/forms/bulk_edit.py:112
 #: netbox/virtualization/forms/bulk_edit.py:33
 #: netbox/virtualization/forms/bulk_edit.py:47
 #: netbox/virtualization/forms/bulk_edit.py:82
@@ -655,13 +656,13 @@ msgstr ""
 #: netbox/circuits/forms/filtersets.py:321 netbox/dcim/forms/bulk_edit.py:216
 #: netbox/dcim/forms/bulk_edit.py:656 netbox/dcim/forms/bulk_edit.py:866
 #: netbox/dcim/forms/bulk_edit.py:1235 netbox/dcim/forms/bulk_edit.py:1262
-#: netbox/dcim/forms/bulk_edit.py:1796 netbox/dcim/forms/filtersets.py:1132
-#: netbox/dcim/forms/filtersets.py:1390 netbox/dcim/forms/filtersets.py:1543
-#: netbox/dcim/forms/filtersets.py:1567 netbox/dcim/tables/devices.py:748
-#: netbox/dcim/tables/devices.py:804 netbox/dcim/tables/devices.py:1045
-#: netbox/dcim/tables/devicetypes.py:256 netbox/dcim/tables/devicetypes.py:271
-#: netbox/dcim/tables/racks.py:33 netbox/extras/forms/bulk_edit.py:303
-#: netbox/extras/tables/tables.py:487
+#: netbox/dcim/forms/bulk_edit.py:1796 netbox/dcim/forms/bulk_import.py:1414
+#: netbox/dcim/forms/filtersets.py:1132 netbox/dcim/forms/filtersets.py:1390
+#: netbox/dcim/forms/filtersets.py:1543 netbox/dcim/forms/filtersets.py:1567
+#: netbox/dcim/tables/devices.py:748 netbox/dcim/tables/devices.py:804
+#: netbox/dcim/tables/devices.py:1045 netbox/dcim/tables/devicetypes.py:256
+#: netbox/dcim/tables/devicetypes.py:271 netbox/dcim/tables/racks.py:33
+#: netbox/extras/forms/bulk_edit.py:303 netbox/extras/tables/tables.py:487
 #: netbox/templates/circuits/circuittype.html:30
 #: netbox/templates/circuits/virtualcircuittype.html:30
 #: netbox/templates/dcim/cable.html:40 netbox/templates/dcim/devicerole.html:38
@@ -692,8 +693,8 @@ msgstr ""
 #: netbox/dcim/forms/bulk_import.py:766 netbox/dcim/forms/bulk_import.py:792
 #: netbox/dcim/forms/bulk_import.py:818 netbox/dcim/forms/bulk_import.py:838
 #: netbox/dcim/forms/bulk_import.py:924 netbox/dcim/forms/bulk_import.py:1018
-#: netbox/dcim/forms/bulk_import.py:1060 netbox/dcim/forms/bulk_import.py:1381
-#: netbox/dcim/forms/bulk_import.py:1544 netbox/dcim/forms/filtersets.py:1023
+#: netbox/dcim/forms/bulk_import.py:1060 netbox/dcim/forms/bulk_import.py:1395
+#: netbox/dcim/forms/bulk_import.py:1604 netbox/dcim/forms/filtersets.py:1023
 #: netbox/dcim/forms/filtersets.py:1122 netbox/dcim/forms/filtersets.py:1243
 #: netbox/dcim/forms/filtersets.py:1315 netbox/dcim/forms/filtersets.py:1340
 #: netbox/dcim/forms/filtersets.py:1364 netbox/dcim/forms/filtersets.py:1384
@@ -763,8 +764,8 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:1819 netbox/dcim/forms/bulk_import.py:91
 #: netbox/dcim/forms/bulk_import.py:150 netbox/dcim/forms/bulk_import.py:254
 #: netbox/dcim/forms/bulk_import.py:563 netbox/dcim/forms/bulk_import.py:717
-#: netbox/dcim/forms/bulk_import.py:1168 netbox/dcim/forms/bulk_import.py:1375
-#: netbox/dcim/forms/bulk_import.py:1539 netbox/dcim/forms/bulk_import.py:1603
+#: netbox/dcim/forms/bulk_import.py:1168 netbox/dcim/forms/bulk_import.py:1389
+#: netbox/dcim/forms/bulk_import.py:1599 netbox/dcim/forms/bulk_import.py:1663
 #: netbox/dcim/forms/filtersets.py:180 netbox/dcim/forms/filtersets.py:239
 #: netbox/dcim/forms/filtersets.py:361 netbox/dcim/forms/filtersets.py:819
 #: netbox/dcim/forms/filtersets.py:944 netbox/dcim/forms/filtersets.py:1026
@@ -841,8 +842,8 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:856 netbox/dcim/forms/bulk_edit.py:1824
 #: netbox/dcim/forms/bulk_import.py:110 netbox/dcim/forms/bulk_import.py:155
 #: netbox/dcim/forms/bulk_import.py:247 netbox/dcim/forms/bulk_import.py:362
-#: netbox/dcim/forms/bulk_import.py:537 netbox/dcim/forms/bulk_import.py:1387
-#: netbox/dcim/forms/bulk_import.py:1596 netbox/dcim/forms/filtersets.py:175
+#: netbox/dcim/forms/bulk_import.py:537 netbox/dcim/forms/bulk_import.py:1401
+#: netbox/dcim/forms/bulk_import.py:1656 netbox/dcim/forms/filtersets.py:175
 #: netbox/dcim/forms/filtersets.py:207 netbox/dcim/forms/filtersets.py:325
 #: netbox/dcim/forms/filtersets.py:401 netbox/dcim/forms/filtersets.py:422
 #: netbox/dcim/forms/filtersets.py:742 netbox/dcim/forms/filtersets.py:936
@@ -1012,7 +1013,7 @@ msgstr ""
 
 #: netbox/circuits/forms/bulk_edit.py:215
 #: netbox/circuits/forms/model_forms.py:171
-#: netbox/dcim/forms/bulk_import.py:1348 netbox/dcim/forms/bulk_import.py:1366
+#: netbox/dcim/forms/bulk_import.py:1355 netbox/dcim/forms/bulk_import.py:1380
 msgid "Termination type"
 msgstr ""
 
@@ -1065,7 +1066,7 @@ msgstr ""
 #: netbox/templates/dcim/virtualchassis.html:68
 #: netbox/templates/dcim/virtualchassis_edit.html:60
 #: netbox/templates/ipam/inc/panels/fhrp_groups.html:26
-#: netbox/tenancy/forms/bulk_edit.py:159 netbox/tenancy/forms/filtersets.py:110
+#: netbox/tenancy/forms/bulk_edit.py:164 netbox/tenancy/forms/filtersets.py:110
 msgid "Priority"
 msgstr ""
 
@@ -1116,7 +1117,7 @@ msgstr ""
 #: netbox/templates/virtualization/virtualmachine.html:23
 #: netbox/templates/vpn/tunneltermination.html:17
 #: netbox/templates/wireless/inc/wirelesslink_interface.html:20
-#: netbox/tenancy/forms/bulk_edit.py:154 netbox/tenancy/forms/filtersets.py:107
+#: netbox/tenancy/forms/bulk_edit.py:159 netbox/tenancy/forms/filtersets.py:107
 #: netbox/tenancy/forms/model_forms.py:139
 #: netbox/tenancy/tables/contacts.py:110
 #: netbox/virtualization/forms/bulk_edit.py:127
@@ -1148,7 +1149,7 @@ msgstr ""
 #: netbox/circuits/forms/bulk_import.py:229 netbox/dcim/forms/bulk_import.py:93
 #: netbox/dcim/forms/bulk_import.py:152 netbox/dcim/forms/bulk_import.py:256
 #: netbox/dcim/forms/bulk_import.py:565 netbox/dcim/forms/bulk_import.py:719
-#: netbox/dcim/forms/bulk_import.py:1170 netbox/dcim/forms/bulk_import.py:1541
+#: netbox/dcim/forms/bulk_import.py:1170 netbox/dcim/forms/bulk_import.py:1601
 #: netbox/ipam/forms/bulk_import.py:197 netbox/ipam/forms/bulk_import.py:265
 #: netbox/ipam/forms/bulk_import.py:301 netbox/ipam/forms/bulk_import.py:498
 #: netbox/ipam/forms/bulk_import.py:511
@@ -1164,8 +1165,8 @@ msgstr ""
 #: netbox/circuits/forms/bulk_import.py:236
 #: netbox/dcim/forms/bulk_import.py:114 netbox/dcim/forms/bulk_import.py:159
 #: netbox/dcim/forms/bulk_import.py:366 netbox/dcim/forms/bulk_import.py:541
-#: netbox/dcim/forms/bulk_import.py:1391 netbox/dcim/forms/bulk_import.py:1536
-#: netbox/dcim/forms/bulk_import.py:1600 netbox/ipam/forms/bulk_import.py:45
+#: netbox/dcim/forms/bulk_import.py:1405 netbox/dcim/forms/bulk_import.py:1596
+#: netbox/dcim/forms/bulk_import.py:1660 netbox/ipam/forms/bulk_import.py:45
 #: netbox/ipam/forms/bulk_import.py:74 netbox/ipam/forms/bulk_import.py:102
 #: netbox/ipam/forms/bulk_import.py:122 netbox/ipam/forms/bulk_import.py:142
 #: netbox/ipam/forms/bulk_import.py:171 netbox/ipam/forms/bulk_import.py:260
@@ -1245,8 +1246,8 @@ msgstr ""
 #: netbox/dcim/forms/bulk_edit.py:466 netbox/dcim/forms/bulk_edit.py:735
 #: netbox/dcim/forms/bulk_edit.py:790 netbox/dcim/forms/bulk_edit.py:944
 #: netbox/dcim/forms/bulk_import.py:241 netbox/dcim/forms/bulk_import.py:343
-#: netbox/dcim/forms/bulk_import.py:604 netbox/dcim/forms/bulk_import.py:1485
-#: netbox/dcim/forms/bulk_import.py:1519 netbox/dcim/forms/filtersets.py:97
+#: netbox/dcim/forms/bulk_import.py:604 netbox/dcim/forms/bulk_import.py:1545
+#: netbox/dcim/forms/bulk_import.py:1579 netbox/dcim/forms/filtersets.py:97
 #: netbox/dcim/forms/filtersets.py:324 netbox/dcim/forms/filtersets.py:358
 #: netbox/dcim/forms/filtersets.py:398 netbox/dcim/forms/filtersets.py:449
 #: netbox/dcim/forms/filtersets.py:739 netbox/dcim/forms/filtersets.py:782
@@ -1948,7 +1949,7 @@ msgstr ""
 #: netbox/dcim/forms/bulk_import.py:1007 netbox/dcim/forms/bulk_import.py:1055
 #: netbox/dcim/forms/bulk_import.py:1072 netbox/dcim/forms/bulk_import.py:1084
 #: netbox/dcim/forms/bulk_import.py:1132 netbox/dcim/forms/bulk_import.py:1254
-#: netbox/dcim/forms/bulk_import.py:1590 netbox/dcim/forms/connections.py:24
+#: netbox/dcim/forms/bulk_import.py:1650 netbox/dcim/forms/connections.py:24
 #: netbox/dcim/forms/filtersets.py:133 netbox/dcim/forms/filtersets.py:941
 #: netbox/dcim/forms/filtersets.py:973 netbox/dcim/forms/filtersets.py:1119
 #: netbox/dcim/forms/filtersets.py:1310 netbox/dcim/forms/filtersets.py:1335
@@ -2017,7 +2018,7 @@ msgstr ""
 msgid "Swapped terminations for circuit {circuit}."
 msgstr ""
 
-#: netbox/core/api/views.py:51
+#: netbox/core/api/views.py:50
 msgid "This user does not have permission to synchronize this data source."
 msgstr ""
 
@@ -2748,7 +2749,7 @@ msgid ""
 "enqueue() cannot be called with values for both schedule_at and immediate."
 msgstr ""
 
-#: netbox/core/signals.py:125
+#: netbox/core/signals.py:131
 #, python-brace-format
 msgid "Deletion is prevented by a protection rule: {message}"
 msgstr ""
@@ -2913,7 +2914,7 @@ msgstr ""
 msgid "No workers found"
 msgstr ""
 
-#: netbox/core/utils.py:84 netbox/core/utils.py:150 netbox/core/views.py:398
+#: netbox/core/utils.py:84 netbox/core/utils.py:150 netbox/core/views.py:393
 #, python-brace-format
 msgid "Job {job_id} not found"
 msgstr ""
@@ -2923,51 +2924,51 @@ msgstr ""
 msgid "Job {id} not found."
 msgstr ""
 
-#: netbox/core/views.py:89
+#: netbox/core/views.py:84
 #, python-brace-format
 msgid "Queued job #{id} to sync {datasource}"
 msgstr ""
 
-#: netbox/core/views.py:334
+#: netbox/core/views.py:329
 #, python-brace-format
 msgid "Restored configuration revision #{id}"
 msgstr ""
 
-#: netbox/core/views.py:437
+#: netbox/core/views.py:432
 #, python-brace-format
 msgid "Job {id} has been deleted."
 msgstr ""
 
-#: netbox/core/views.py:439
+#: netbox/core/views.py:434
 #, python-brace-format
 msgid "Error deleting job {id}: {error}"
 msgstr ""
 
-#: netbox/core/views.py:448
+#: netbox/core/views.py:443
 #, python-brace-format
 msgid "Job {id} has been re-enqueued."
 msgstr ""
 
-#: netbox/core/views.py:457
+#: netbox/core/views.py:452
 #, python-brace-format
 msgid "Job {id} has been enqueued."
 msgstr ""
 
-#: netbox/core/views.py:466
+#: netbox/core/views.py:461
 #, python-brace-format
 msgid "Job {id} has been stopped."
 msgstr ""
 
-#: netbox/core/views.py:468
+#: netbox/core/views.py:463
 #, python-brace-format
 msgid "Failed to stop job {id}"
 msgstr ""
 
-#: netbox/core/views.py:603
+#: netbox/core/views.py:598
 msgid "Plugins catalog could not be loaded"
 msgstr ""
 
-#: netbox/core/views.py:639
+#: netbox/core/views.py:634
 #, python-brace-format
 msgid "Plugin {name} not found"
 msgstr ""
@@ -3106,7 +3107,7 @@ msgstr ""
 #: netbox/templates/tenancy/tenantgroup.html:37
 #: netbox/templates/virtualization/vminterface.html:39
 #: netbox/templates/wireless/wirelesslangroup.html:37
-#: netbox/tenancy/forms/bulk_edit.py:27 netbox/tenancy/forms/bulk_edit.py:62
+#: netbox/tenancy/forms/bulk_edit.py:27 netbox/tenancy/forms/bulk_edit.py:67
 #: netbox/tenancy/forms/bulk_import.py:24
 #: netbox/tenancy/forms/bulk_import.py:58
 #: netbox/tenancy/forms/model_forms.py:25
@@ -4193,8 +4194,8 @@ msgstr ""
 
 #: netbox/dcim/forms/bulk_edit.py:465 netbox/dcim/forms/bulk_edit.py:972
 #: netbox/dcim/forms/bulk_import.py:350 netbox/dcim/forms/bulk_import.py:353
-#: netbox/dcim/forms/bulk_import.py:611 netbox/dcim/forms/bulk_import.py:1526
-#: netbox/dcim/forms/bulk_import.py:1530 netbox/dcim/forms/filtersets.py:106
+#: netbox/dcim/forms/bulk_import.py:611 netbox/dcim/forms/bulk_import.py:1586
+#: netbox/dcim/forms/bulk_import.py:1590 netbox/dcim/forms/filtersets.py:106
 #: netbox/dcim/forms/filtersets.py:326 netbox/dcim/forms/filtersets.py:407
 #: netbox/dcim/forms/filtersets.py:421 netbox/dcim/forms/filtersets.py:459
 #: netbox/dcim/forms/filtersets.py:792 netbox/dcim/forms/filtersets.py:1005
@@ -4394,8 +4395,8 @@ msgstr ""
 msgid "Length"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_edit.py:875 netbox/dcim/forms/bulk_import.py:1394
-#: netbox/dcim/forms/bulk_import.py:1397 netbox/dcim/forms/filtersets.py:1140
+#: netbox/dcim/forms/bulk_edit.py:875 netbox/dcim/forms/bulk_import.py:1408
+#: netbox/dcim/forms/bulk_import.py:1411 netbox/dcim/forms/filtersets.py:1140
 msgid "Length unit"
 msgstr ""
 
@@ -4404,17 +4405,17 @@ msgstr ""
 msgid "Domain"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_edit.py:967 netbox/dcim/forms/bulk_import.py:1513
+#: netbox/dcim/forms/bulk_edit.py:967 netbox/dcim/forms/bulk_import.py:1573
 #: netbox/dcim/forms/filtersets.py:1226 netbox/dcim/forms/model_forms.py:855
 msgid "Power panel"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_edit.py:989 netbox/dcim/forms/bulk_import.py:1549
+#: netbox/dcim/forms/bulk_edit.py:989 netbox/dcim/forms/bulk_import.py:1609
 #: netbox/dcim/forms/filtersets.py:1248 netbox/templates/dcim/powerfeed.html:83
 msgid "Supply"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_edit.py:995 netbox/dcim/forms/bulk_import.py:1554
+#: netbox/dcim/forms/bulk_edit.py:995 netbox/dcim/forms/bulk_import.py:1614
 #: netbox/dcim/forms/filtersets.py:1253 netbox/templates/dcim/powerfeed.html:95
 msgid "Phase"
 msgstr ""
@@ -4652,7 +4653,7 @@ msgid "available options"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:137 netbox/dcim/forms/bulk_import.py:601
-#: netbox/dcim/forms/bulk_import.py:1510 netbox/ipam/forms/bulk_import.py:479
+#: netbox/dcim/forms/bulk_import.py:1570 netbox/ipam/forms/bulk_import.py:479
 #: netbox/virtualization/forms/bulk_import.py:64
 #: netbox/virtualization/forms/bulk_import.py:95
 msgid "Assigned site"
@@ -4715,7 +4716,7 @@ msgstr ""
 msgid "Parent site"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:347 netbox/dcim/forms/bulk_import.py:1523
+#: netbox/dcim/forms/bulk_import.py:347 netbox/dcim/forms/bulk_import.py:1583
 msgid "Rack's location (if any)"
 msgstr ""
 
@@ -4766,7 +4767,7 @@ msgstr ""
 msgid "Limit platform assignments to this manufacturer"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:534 netbox/dcim/forms/bulk_import.py:1593
+#: netbox/dcim/forms/bulk_import.py:534 netbox/dcim/forms/bulk_import.py:1653
 #: netbox/tenancy/forms/bulk_import.py:105
 msgid "Assigned role"
 msgstr ""
@@ -4956,7 +4957,7 @@ msgid "Corresponding rear port"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:1020 netbox/dcim/forms/bulk_import.py:1061
-#: netbox/dcim/forms/bulk_import.py:1384
+#: netbox/dcim/forms/bulk_import.py:1398
 msgid "Physical medium classification"
 msgstr ""
 
@@ -5045,102 +5046,131 @@ msgid "Must specify the parent device or VM when assigning an interface"
 msgstr ""
 
 #: netbox/dcim/forms/bulk_import.py:1339
+msgid "Side A site"
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1343
+#: netbox/wireless/forms/bulk_import.py:94
+msgid "Site of parent device A (if any)"
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1346
 msgid "Side A device"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1342 netbox/dcim/forms/bulk_import.py:1360
+#: netbox/dcim/forms/bulk_import.py:1349 netbox/dcim/forms/bulk_import.py:1374
 msgid "Device name"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1345
+#: netbox/dcim/forms/bulk_import.py:1352
 msgid "Side A type"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1351
+#: netbox/dcim/forms/bulk_import.py:1358
 msgid "Side A name"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1352 netbox/dcim/forms/bulk_import.py:1370
+#: netbox/dcim/forms/bulk_import.py:1359 netbox/dcim/forms/bulk_import.py:1384
 msgid "Termination name"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1357
+#: netbox/dcim/forms/bulk_import.py:1364
+msgid "Side B site"
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1368
+#: netbox/wireless/forms/bulk_import.py:115
+msgid "Site of parent device B (if any)"
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1371
 msgid "Side B device"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1363
+#: netbox/dcim/forms/bulk_import.py:1377
 msgid "Side B type"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1369
+#: netbox/dcim/forms/bulk_import.py:1383
 msgid "Side B name"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1378
+#: netbox/dcim/forms/bulk_import.py:1392
 #: netbox/wireless/forms/bulk_import.py:134
 msgid "Connection status"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1430
+#: netbox/dcim/forms/bulk_import.py:1417
+msgid "Color name (e.g. \"Red\") or hex code (e.g. \"f44336\")"
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1469
 #, python-brace-format
 msgid "Side {side_upper}: {device} {termination_object} is already connected"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1436
+#: netbox/dcim/forms/bulk_import.py:1475
 #, python-brace-format
 msgid "{side_upper} side termination not found: {device} {name}"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1461 netbox/dcim/forms/model_forms.py:891
+#: netbox/dcim/forms/bulk_import.py:1496
+#, python-brace-format
+msgid ""
+"{color} did not match any used color name and was longer than six "
+"characters: invalid hex."
+msgstr ""
+
+#: netbox/dcim/forms/bulk_import.py:1521 netbox/dcim/forms/model_forms.py:891
 #: netbox/dcim/tables/devices.py:1069 netbox/templates/dcim/device.html:138
 #: netbox/templates/dcim/virtualchassis.html:27
 #: netbox/templates/dcim/virtualchassis.html:67
 msgid "Master"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1465
+#: netbox/dcim/forms/bulk_import.py:1525
 msgid "Master device"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1482
+#: netbox/dcim/forms/bulk_import.py:1542
 msgid "Name of parent site"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1516
+#: netbox/dcim/forms/bulk_import.py:1576
 msgid "Upstream power panel"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1546
+#: netbox/dcim/forms/bulk_import.py:1606
 msgid "Primary or redundant"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1551
+#: netbox/dcim/forms/bulk_import.py:1611
 msgid "Supply type (AC/DC)"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1556
+#: netbox/dcim/forms/bulk_import.py:1616
 msgid "Single or three-phase"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1607 netbox/dcim/forms/model_forms.py:1847
+#: netbox/dcim/forms/bulk_import.py:1667 netbox/dcim/forms/model_forms.py:1847
 #: netbox/templates/dcim/device.html:196
 #: netbox/templates/dcim/virtualdevicecontext.html:30
 #: netbox/templates/virtualization/virtualmachine.html:52
 msgid "Primary IPv4"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1611
+#: netbox/dcim/forms/bulk_import.py:1671
 msgid "IPv4 address with mask, e.g. 1.2.3.4/24"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1614 netbox/dcim/forms/model_forms.py:1856
+#: netbox/dcim/forms/bulk_import.py:1674 netbox/dcim/forms/model_forms.py:1856
 #: netbox/templates/dcim/device.html:212
 #: netbox/templates/dcim/virtualdevicecontext.html:41
 #: netbox/templates/virtualization/virtualmachine.html:68
 msgid "Primary IPv6"
 msgstr ""
 
-#: netbox/dcim/forms/bulk_import.py:1618
+#: netbox/dcim/forms/bulk_import.py:1678
 msgid "IPv6 address with prefix length, e.g. 2001:db8::1/64"
 msgstr ""
 
@@ -7784,7 +7814,7 @@ msgid "No"
 msgstr ""
 
 #: netbox/extras/choices.py:108 netbox/templates/tenancy/contact.html:67
-#: netbox/tenancy/forms/bulk_edit.py:125
+#: netbox/tenancy/forms/bulk_edit.py:130
 #: netbox/wireless/forms/model_forms.py:173
 msgid "Link"
 msgstr ""
@@ -8146,7 +8176,7 @@ msgstr ""
 
 #: netbox/extras/forms/bulk_edit.py:155 netbox/extras/forms/bulk_edit.py:354
 #: netbox/extras/forms/filtersets.py:192 netbox/extras/forms/filtersets.py:470
-#: netbox/extras/models/mixins.py:100
+#: netbox/extras/models/mixins.py:101
 msgid "MIME type"
 msgstr ""
 
@@ -8325,7 +8355,7 @@ msgstr ""
 #: netbox/netbox/navigation/menu.py:433
 #: netbox/templates/extras/notificationgroup.html:31
 #: netbox/templates/tenancy/contact.html:21
-#: netbox/tenancy/forms/bulk_edit.py:139 netbox/tenancy/forms/filtersets.py:78
+#: netbox/tenancy/forms/bulk_edit.py:144 netbox/tenancy/forms/filtersets.py:78
 #: netbox/tenancy/forms/model_forms.py:99 netbox/tenancy/tables/contacts.py:68
 #: netbox/users/forms/model_forms.py:182 netbox/users/forms/model_forms.py:194
 #: netbox/users/forms/model_forms.py:306 netbox/users/tables.py:35
@@ -9025,51 +9055,51 @@ msgstr ""
 msgid "dashboards"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:85
+#: netbox/extras/models/mixins.py:86
 msgid "template code"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:86
+#: netbox/extras/models/mixins.py:87
 msgid "Jinja template code."
 msgstr ""
 
-#: netbox/extras/models/mixins.py:89
+#: netbox/extras/models/mixins.py:90
 msgid "environment parameters"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:94
+#: netbox/extras/models/mixins.py:95
 #, python-brace-format
 msgid ""
 "Any <a href=\"{url}\">additional parameters</a> to pass when constructing "
 "the Jinja environment"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:101
+#: netbox/extras/models/mixins.py:102
 #, python-brace-format
 msgid "Defaults to <code>{default}</code>"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:106
+#: netbox/extras/models/mixins.py:107
 msgid "Filename to give to the rendered export file"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:109
+#: netbox/extras/models/mixins.py:110
 msgid "file extension"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:112
+#: netbox/extras/models/mixins.py:113
 msgid "Extension to append to the rendered filename"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:115
+#: netbox/extras/models/mixins.py:116
 msgid "as attachment"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:117
+#: netbox/extras/models/mixins.py:118
 msgid "Download file as attachment"
 msgstr ""
 
-#: netbox/extras/models/mixins.py:124
+#: netbox/extras/models/mixins.py:125
 #, python-brace-format
 msgid "{class_name} must implement a get_context() method."
 msgstr ""
@@ -9855,7 +9885,7 @@ msgstr ""
 #: netbox/ipam/filtersets.py:466 netbox/ipam/filtersets.py:470
 #: netbox/ipam/filtersets.py:562 netbox/ipam/forms/model_forms.py:506
 #: netbox/templates/tenancy/contact.html:63
-#: netbox/tenancy/forms/bulk_edit.py:120
+#: netbox/tenancy/forms/bulk_edit.py:125
 msgid "Address"
 msgstr ""
 
@@ -12311,7 +12341,7 @@ msgstr ""
 
 #: netbox/templates/account/profile.html:27
 #: netbox/templates/tenancy/contact.html:53 netbox/templates/users/user.html:23
-#: netbox/tenancy/forms/bulk_edit.py:116
+#: netbox/tenancy/forms/bulk_edit.py:121
 msgid "Email"
 msgstr ""
 
@@ -14826,7 +14856,7 @@ msgid ""
 msgstr ""
 
 #: netbox/templates/tenancy/contact.html:18 netbox/tenancy/filtersets.py:152
-#: netbox/tenancy/forms/bulk_edit.py:149 netbox/tenancy/forms/filtersets.py:102
+#: netbox/tenancy/forms/bulk_edit.py:154 netbox/tenancy/forms/filtersets.py:102
 #: netbox/tenancy/forms/forms.py:57 netbox/tenancy/forms/model_forms.py:108
 #: netbox/tenancy/forms/model_forms.py:132
 #: netbox/tenancy/tables/contacts.py:106
@@ -14834,12 +14864,12 @@ msgid "Contact"
 msgstr ""
 
 #: netbox/templates/tenancy/contact.html:39
-#: netbox/tenancy/forms/bulk_edit.py:106
+#: netbox/tenancy/forms/bulk_edit.py:111
 msgid "Title"
 msgstr ""
 
 #: netbox/templates/tenancy/contact.html:43
-#: netbox/tenancy/forms/bulk_edit.py:111 netbox/tenancy/tables/contacts.py:72
+#: netbox/tenancy/forms/bulk_edit.py:116 netbox/tenancy/tables/contacts.py:72
 msgid "Phone"
 msgstr ""
 
@@ -15208,15 +15238,15 @@ msgstr ""
 msgid "Tenant Group (slug)"
 msgstr ""
 
-#: netbox/tenancy/forms/bulk_edit.py:67
+#: netbox/tenancy/forms/bulk_edit.py:72
 msgid "Desciption"
 msgstr ""
 
-#: netbox/tenancy/forms/bulk_edit.py:96
+#: netbox/tenancy/forms/bulk_edit.py:101
 msgid "Add groups"
 msgstr ""
 
-#: netbox/tenancy/forms/bulk_edit.py:101
+#: netbox/tenancy/forms/bulk_edit.py:106
 msgid "Remove groups"
 msgstr ""
 
@@ -16673,10 +16703,6 @@ msgstr ""
 msgid "Bridged VLAN"
 msgstr ""
 
-#: netbox/wireless/forms/bulk_import.py:94
-msgid "Site of parent device A (if any)"
-msgstr ""
-
 #: netbox/wireless/forms/bulk_import.py:100
 msgid "Parent device of assigned interface A"
 msgstr ""
@@ -16690,10 +16716,6 @@ msgstr ""
 msgid "Assigned interface A"
 msgstr ""
 
-#: netbox/wireless/forms/bulk_import.py:115
-msgid "Site of parent device B (if any)"
-msgstr ""
-
 #: netbox/wireless/forms/bulk_import.py:121
 msgid "Parent device of assigned interface B"
 msgstr ""

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 17 - 0
netbox/users/migrations/0010_add_token_meta_ordering.py

@@ -0,0 +1,17 @@
+# Generated by Django 5.2.4 on 2025-07-23 17:28
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0009_update_group_perms'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='token',
+            options={'ordering': ('-created',)},
+        ),
+    ]

+ 1 - 1
netbox/users/migrations/0010_concrete_objecttype.py → netbox/users/migrations/0011_concrete_objecttype.py

@@ -5,7 +5,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('contenttypes', '0002_remove_content_type_name'),
-        ('users', '0009_update_group_perms'),
+        ('users', '0010_add_token_meta_ordering'),
     ]
 
     operations = [

+ 1 - 0
netbox/users/models/tokens.py

@@ -74,6 +74,7 @@ class Token(models.Model):
     class Meta:
         verbose_name = _('token')
         verbose_name_plural = _('tokens')
+        ordering = ('-created',)
 
     def __str__(self):
         return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial

+ 2 - 0
netbox/utilities/constants.py

@@ -13,6 +13,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
     ie='iexact',
     nie='iexact',
     empty='empty',
+    regex='regex',
+    iregex='iregex',
 )
 
 FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(

+ 3 - 0
netbox/utilities/query.py

@@ -67,5 +67,8 @@ def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
     # MPTT-based models are exempt from this; use caution when annotating querysets of these models
     if any(isinstance(manager, TreeManager) for manager in queryset.model._meta.local_managers):
         return queryset
+    elif queryset.ordered:
+        return queryset
+
     ordering = queryset.model._meta.ordering
     return queryset.order_by(*ordering)

+ 32 - 0
netbox/utilities/tests/test_filters.py

@@ -180,6 +180,10 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['charfield__niew'].exclude, True)
         self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
         self.assertEqual(self.filters['charfield__empty'].exclude, False)
+        self.assertEqual(self.filters['charfield__regex'].lookup_expr, 'regex')
+        self.assertEqual(self.filters['charfield__regex'].exclude, False)
+        self.assertEqual(self.filters['charfield__iregex'].lookup_expr, 'iregex')
+        self.assertEqual(self.filters['charfield__iregex'].exclude, False)
 
     def test_number_filter(self):
         self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
@@ -220,6 +224,10 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['macaddressfield__iew'].exclude, False)
         self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith')
         self.assertEqual(self.filters['macaddressfield__niew'].exclude, True)
+        self.assertEqual(self.filters['macaddressfield__regex'].lookup_expr, 'regex')
+        self.assertEqual(self.filters['macaddressfield__regex'].exclude, False)
+        self.assertEqual(self.filters['macaddressfield__iregex'].lookup_expr, 'iregex')
+        self.assertEqual(self.filters['macaddressfield__iregex'].exclude, False)
 
     def test_model_choice_filter(self):
         self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter)
@@ -257,6 +265,10 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False)
         self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith')
         self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True)
+        self.assertEqual(self.filters['multivaluecharfield__regex'].lookup_expr, 'regex')
+        self.assertEqual(self.filters['multivaluecharfield__regex'].exclude, False)
+        self.assertEqual(self.filters['multivaluecharfield__iregex'].lookup_expr, 'iregex')
+        self.assertEqual(self.filters['multivaluecharfield__iregex'].exclude, False)
 
     def test_multi_value_date_filter(self):
         self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter)
@@ -340,6 +352,10 @@ class BaseFilterSetTest(TestCase):
         self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False)
         self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith')
         self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True)
+        self.assertEqual(self.filters['multiplechoicefield__regex'].lookup_expr, 'regex')
+        self.assertEqual(self.filters['multiplechoicefield__regex'].exclude, False)
+        self.assertEqual(self.filters['multiplechoicefield__iregex'].lookup_expr, 'iregex')
+        self.assertEqual(self.filters['multiplechoicefield__iregex'].exclude, False)
 
     def test_tag_filter(self):
         self.assertIsInstance(self.filters['tagfield'], TagFilter)
@@ -534,6 +550,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
         params = {'slug__niew': ['-1']}
         self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
 
+    def test_site_slug_regex(self):
+        params = {'slug__regex': ['^def-[a-z]*-2$']}
+        self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
+
+    def test_site_slug_iregex(self):
+        params = {'slug__iregex': ['^DEF-[a-z]*-2$']}
+        self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
+
     def test_provider_asn_lt(self):
         params = {'asn__lt': [65101]}
         self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
@@ -618,6 +642,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
         params = {'mac_address__nic': ['aa:', 'bb']}
         self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
 
+    def test_device_mac_address_regex(self):
+        params = {'mac_address__regex': ['^cc.*:03$']}
+        self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
+
+    def test_device_mac_address_iregex(self):
+        params = {'mac_address__iregex': ['^CC.*:03$']}
+        self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
+
     def test_interface_rf_role_empty(self):
         params = {'rf_role__empty': 'true'}
         self.assertEqual(InterfaceFilterSet(params, Interface.objects.all()).qs.count(), 5)

+ 1 - 1
pyproject.toml

@@ -3,7 +3,7 @@
 
 [project]
 name = "netbox"
-version = "4.3.4"
+version = "4.3.5"
 requires-python = ">=3.10"
 authors = [
     { name = "NetBox Community" }

+ 7 - 7
requirements.txt

@@ -20,21 +20,21 @@ drf-spectacular-sidecar==2025.7.1
 feedparser==6.0.11
 gunicorn==23.0.0
 Jinja2==3.1.6
-jsonschema==4.24.0
+jsonschema==4.25.0
 Markdown==3.8.2
-mkdocs-material==9.6.15
-mkdocstrings[python]==0.29.1
+mkdocs-material==9.6.16
+mkdocstrings[python]==0.30.0
 netaddr==1.3.0
-nh3==0.2.22
+nh3==0.3.0
 Pillow==11.3.0
 psycopg[c,pool]==3.2.9
 PyYAML==6.0.2
 requests==2.32.4
-rq==2.4.0
+rq==2.4.1
 social-auth-app-django==5.5.1
 social-auth-core==4.7.0
-strawberry-graphql==0.276.0
-strawberry-graphql-django==0.60.0
+strawberry-graphql==0.278.0
+strawberry-graphql-django==0.65.1
 svgwrite==1.4.3
 tablib==3.8.0
 tzdata==2025.2

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