Просмотр исходного кода

Merge branch 'main' into feature

Jeremy Stretch 1 месяц назад
Родитель
Сommit
28a5c7d882

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

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

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

+ 0 - 37
.github/workflows/claude-code-review.yml

@@ -1,37 +0,0 @@
-name: Claude Code Review
-
-on:
-  pull_request:
-    types: [opened, synchronize, ready_for_review, reopened]
-
-jobs:
-  claude-review:
-    # Only run for PRs submitted by organization members or owners
-    if: |
-      github.repository == 'netbox-community/netbox' &&
-      (github.event.pull_request.author_association == 'MEMBER' ||
-      github.event.pull_request.author_association == 'OWNER')
-
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      pull-requests: read
-      issues: read
-      id-token: write
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-        with:
-          fetch-depth: 1
-
-      - name: Run Claude Code Review
-        id: claude-review
-        uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
-        with:
-          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-          plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
-          plugins: 'code-review@claude-code-plugins'
-          prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
-          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
-          # or https://code.claude.com/docs/en/cli-reference for available options

+ 5 - 4
.github/workflows/claude-issue-triage.yml

@@ -11,19 +11,18 @@ jobs:
     permissions:
       contents: read
       issues: write
-
     steps:
       - name: Checkout repository
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           fetch-depth: 1
-
       - name: Run Claude Issue Triage
         id: claude-triage
-        uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
+        uses: anthropics/claude-code-action@11a9dadd198803a0cea6bd53da3e0e8a762fc6ea # v1.0.108
         with:
           anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
           github_token: ${{ secrets.GITHUB_TOKEN }}
+          allowed_non_write_users: "*"
           # Restrict Claude to read-only inspection of the repo plus posting a single comment
           # on THIS issue only. `gh issue comment` is pinned to the current issue number, so an
           # injection cannot redirect a comment to another issue. Close, label, reopen, assign,
@@ -33,7 +32,7 @@ jobs:
           # reduce the blast radius of an injection that tries to dump runner env vars or
           # secrets into a comment body.
           claude_args: >-
-            --allowed-tools
+            --allowedTools
             "Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search issues:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh release list:*),Bash(gh release view:*),Read,Grep,Glob"
           prompt: |
             You are triaging a newly opened issue in the netbox-community/netbox repository.
@@ -129,6 +128,8 @@ jobs:
             - Reference the specific problem(s) and clearly explain what the submitter can do
               to move the issue forward (e.g. "please edit the issue to include reproduction
               steps" or "this appears to duplicate #12345 — could you confirm?").
+            - Never direct the submitter to proceed with a pull request immediately: A
+              maintainer will decide when that is appropriate.
             - Sign off noting that you are an automated triage assistant and a human maintainer
               will follow up.
             - Paraphrase rather than quoting issue content verbatim. Do not echo back links,

+ 14 - 55
.github/workflows/claude.yml

@@ -5,76 +5,35 @@ on:
     types: [created]
   pull_request_review_comment:
     types: [created]
-  issues:
-    types: [opened, assigned]
   pull_request_review:
     types: [submitted]
 
+concurrency:
+  group: claude-${{ github.event.pull_request.number || github.event.issue.number }}
+  cancel-in-progress: true
+
 jobs:
   claude:
     if: |
-      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
-      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
-      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
-      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+      (github.event_name != 'issue_comment' || github.event.issue.pull_request != null)
+      && contains(github.event.comment.body || github.event.review.body, '@claude')
+      && (github.event.comment.user.type || github.event.review.user.type) != 'Bot'
+      && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association)
     runs-on: ubuntu-latest
+    timeout-minutes: 15
     permissions:
       contents: read
-      pull-requests: read
-      issues: read
-      id-token: write
+      issues: write
+      pull-requests: write
       actions: read # Required for Claude to read CI results on PRs
     steps:
       - name: Checkout repository
         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           fetch-depth: 1
-
-      # Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
-      # (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
-      # Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
-      - name: Configure git remote for fork PRs
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: |
-          # Determine PR number based on event type
-          if [ "${{ github.event_name }}" = "issue_comment" ]; then
-            PR_NUMBER="${{ github.event.issue.number }}"
-          elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
-            PR_NUMBER="${{ github.event.pull_request.number }}"
-          else
-            exit 0  # issues event — no PR branch to worry about
-          fi
-
-          # Fetch fork info in one API call; silently skip if this is not a PR
-          PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
-          if [ -z "$PR_INFO" ]; then
-            exit 0
-          fi
-
-          IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
-          if [ "$IS_FORK" = "true" ]; then
-            FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
-            FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
-            echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
-            git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
-          fi
-
       - name: Run Claude Code
         id: claude
-        uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
+        uses: anthropics/claude-code-action@11a9dadd198803a0cea6bd53da3e0e8a762fc6ea # v1.0.108
         with:
-          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-
-          # This is an optional setting that allows Claude to read CI results on PRs
-          additional_permissions: |
-            actions: read
-
-          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
-          # prompt: 'Update the pull request description to include a summary of changes.'
-
-          # Optional: Add claude_args to customize behavior and configuration
-          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
-          # or https://code.claude.com/docs/en/cli-reference for available options
-          # claude_args: '--allowed-tools Bash(gh pr:*)'
-
+          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
+          github_token: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 1
base_requirements.txt

@@ -148,7 +148,8 @@ social-auth-app-django
 
 # Social authentication framework
 # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
-social-auth-core
+# Need to verify that v4.9.0 does not introduce breaking changes (see #22095)
+social-auth-core==4.8.*
 
 # Image thumbnail generation
 # https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst

Разница между файлами не показана из-за своего большого размера
+ 0 - 5893
contrib/openapi.json


+ 12 - 0
docs/release-notes/version-4.5.md

@@ -1,5 +1,17 @@
 # NetBox v4.5
 
+## v4.5.10 (2026-05-04)
+
+### Bug Fixes
+
+* [#21990](https://github.com/netbox-community/netbox/issues/21990) - Fix erroneous deletion of device assignment when editing a virtual machine
+* [#22005](https://github.com/netbox-community/netbox/issues/22005) - Fix filtering of interfaces by `connected` status to exclude incomplete cable paths
+* [#22029](https://github.com/netbox-community/netbox/issues/22029) - Recast empty string values as null for unique nullable fields to avoid integrity errors
+* [#22031](https://github.com/netbox-community/netbox/issues/22031) - Fix error when adding a prefix from a VLAN with no tenant/site
+* [#22084](https://github.com/netbox-community/netbox/issues/22084) - Correct OpenAPI schema for `cable_end` field on cabled objects to indicate it may be null
+
+---
+
 ## v4.5.9 (2026-04-28)
 
 ### Enhancements

+ 2 - 1
netbox/dcim/api/serializers_/cables.py

@@ -109,7 +109,8 @@ class CablePathSerializer(serializers.ModelSerializer):
 
 class CabledObjectSerializer(serializers.ModelSerializer):
     cable = CableSerializer(nested=True, read_only=True, allow_null=True)
-    cable_end = serializers.CharField(read_only=True)
+    # Use DRF's ChoiceField; NetBox's ChoiceField would return a value/label object.
+    cable_end = serializers.ChoiceField(choices=CableEndChoices, read_only=True, allow_null=True)
     link_peers_type = serializers.SerializerMethodField(read_only=True, allow_null=True)
     link_peers = serializers.SerializerMethodField(read_only=True)
     _occupied = serializers.SerializerMethodField(read_only=True)

+ 2 - 2
netbox/dcim/filtersets.py

@@ -1929,8 +1929,8 @@ class PathEndpointFilterSet(django_filters.FilterSet):
 
     def filter_connected(self, queryset, name, value):
         if value:
-            return queryset.filter(_path__is_active=True)
-        return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
+            return queryset.filter(_path__is_active=True, _path__is_complete=True)
+        return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False) | Q(_path__is_complete=False))
 
 
 @register_filterset

+ 7 - 0
netbox/dcim/forms/bulk_edit.py

@@ -1804,6 +1804,13 @@ class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm):
     )
     nullable_fields = ('device', 'tenant', )
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # The ?device=<id> GET param is navigation context (filter), not an intent to change the
+        # device field — drop it from initial so Django's changed_data doesn't treat it as an edit.
+        self.initial.pop('device', None)
+
 
 #
 # Addressing

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

@@ -4631,6 +4631,11 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Device.objects.bulk_create(devices)
 
+        # Expose base devices for regression tests which need custom cabling
+        # topologies.
+        cls.connection_filter_device = devices[0]
+        cls.connection_filter_peer_device = devices[1]
+
         virtual_chassis.master = devices[0]
         virtual_chassis.save()
 
@@ -5064,6 +5069,117 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'connected': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_connected_excludes_incomplete_pass_through_path(self):
+        """
+        Validate that connected=true requires a complete cable path, not merely
+        an active cable path.
+
+        The incomplete path below models:
+
+            interface -- front port -- rear port
+
+        with no onward cable from the rear port.
+        """
+        device = self.connection_filter_device
+        peer_device = self.connection_filter_peer_device
+
+        connected_interface = Interface.objects.create(
+            device=device,
+            name='Connected Filter Interface',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        connected_peer_interface = Interface.objects.create(
+            device=peer_device,
+            name='Connected Filter Peer Interface',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        incomplete_path_interface = Interface.objects.create(
+            device=device,
+            name='Connected Filter Incomplete Path Interface',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+
+        patch_panel = Device.objects.create(
+            name='Connected Filter Patch Panel',
+            site=device.site,
+            device_type=device.device_type,
+            role=device.role,
+        )
+        rear_port = RearPort.objects.create(
+            device=patch_panel,
+            name='Patch Rear Port',
+            type=PortTypeChoices.TYPE_8P8C,
+            positions=1,
+        )
+        front_port = FrontPort.objects.create(
+            device=patch_panel,
+            name='Patch Front Port',
+            type=PortTypeChoices.TYPE_8P8C,
+        )
+        PortMapping.objects.create(
+            device=patch_panel,
+            front_port=front_port,
+            front_port_position=1,
+            rear_port=rear_port,
+            rear_port_position=1,
+        )
+
+        Cable(
+            a_terminations=[connected_interface],
+            b_terminations=[connected_peer_interface],
+        ).save()
+        Cable(
+            a_terminations=[incomplete_path_interface],
+            b_terminations=[front_port],
+        ).save()
+
+        connected_interface.refresh_from_db()
+        connected_peer_interface.refresh_from_db()
+        incomplete_path_interface.refresh_from_db()
+
+        self.assertTrue(connected_interface._path.is_active)
+        self.assertTrue(connected_interface._path.is_complete)
+        self.assertTrue(connected_peer_interface._path.is_active)
+        self.assertTrue(connected_peer_interface._path.is_complete)
+
+        self.assertTrue(incomplete_path_interface._path.is_active)
+        self.assertFalse(incomplete_path_interface._path.is_complete)
+
+        queryset = self.queryset.filter(
+            pk__in=(
+                connected_interface.pk,
+                connected_peer_interface.pk,
+                incomplete_path_interface.pk,
+            )
+        )
+
+        params = {'cabled': 'true'}
+        self.assertSetEqual(
+            set(self.filterset(params, queryset).qs.values_list('pk', flat=True)),
+            {
+                connected_interface.pk,
+                connected_peer_interface.pk,
+                incomplete_path_interface.pk,
+            },
+        )
+
+        params = {'connected': 'true'}
+        self.assertSetEqual(
+            set(self.filterset(params, queryset).qs.values_list('pk', flat=True)),
+            {
+                connected_interface.pk,
+                connected_peer_interface.pk,
+            },
+        )
+
+        params = {'connected': 'false'}
+        self.assertSetEqual(
+            set(self.filterset(params, queryset).qs.values_list('pk', flat=True)),
+            {
+                incomplete_path_interface.pk,
+            },
+        )
+
     def test_kind(self):
         params = {'kind': 'physical'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)

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

@@ -638,6 +638,42 @@ class DeviceTestCase(TestCase):
         device2.full_clean()
         device2.save()
 
+    def test_empty_asset_tag_coerced_to_null_on_clean(self):
+        """
+        An empty string assigned to a unique nullable CharField (e.g. asset_tag) must be coerced
+        to None on save so that multiple objects can be saved without violating the unique
+        constraint. Test that this is done on clean().
+        """
+        common_kwargs = {
+            'site': Site.objects.first(),
+            'device_type': DeviceType.objects.first(),
+            'role': DeviceRole.objects.first(),
+        }
+        device1 = Device(name='Device 1', asset_tag='', **common_kwargs)
+        device1.clean()
+        self.assertIsNone(device1.asset_tag)
+
+    def test_empty_asset_tag_coerced_to_null_on_save(self):
+        """
+        An empty string assigned to a unique nullable CharField (e.g. asset_tag) must be coerced
+        to None on save so that multiple objects can be saved without violating the unique
+        constraint. Test that this is done on save().
+        """
+        common_kwargs = {
+            'site': Site.objects.first(),
+            'device_type': DeviceType.objects.first(),
+            'role': DeviceRole.objects.first(),
+        }
+        device1 = Device(name='Device 1', asset_tag='', **common_kwargs)
+        device1.save()
+        device2 = Device(name='Device 2', asset_tag='', **common_kwargs)
+        device2.save()
+
+        device1.refresh_from_db()
+        device2.refresh_from_db()
+        self.assertIsNone(device1.asset_tag)
+        self.assertIsNone(device2.asset_tag)
+
     def test_device_label(self):
         device1 = Device(
             site=Site.objects.first(),

+ 27 - 0
netbox/dcim/tests/test_views.py

@@ -4176,6 +4176,33 @@ class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
         }
 
+    def test_bulk_edit_device_context_preserves_device(self):
+        """
+        Regression test: Bulk editing VDCs from the Device's VDCs tab (URL contains
+        ?device=<id>) must not clear the device field on those VDCs.
+        """
+        self.add_permissions('dcim.view_virtualdevicecontext', 'dcim.change_virtualdevicecontext')
+
+        device = VirtualDeviceContext.objects.filter(device__isnull=False).first().device
+        vdcs = list(VirtualDeviceContext.objects.filter(device=device)[:3])
+        pk_list = [vdc.pk for vdc in vdcs]
+
+        data = {
+            'pk': pk_list,
+            '_apply': True,
+            # Only change status — device is intentionally omitted
+            'status': VirtualDeviceContextStatusChoices.STATUS_PLANNED,
+        }
+
+        # Simulate navigation from Device -> VDCs tab by passing ?device=<id> as GET param
+        url = reverse('dcim:virtualdevicecontext_bulk_edit') + f'?device={device.pk}'
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 302)
+
+        for vdc in VirtualDeviceContext.objects.filter(pk__in=pk_list):
+            self.assertEqual(vdc.device, device, msg=f"Device was unexpectedly cleared on VDC '{vdc.name}'")
+            self.assertEqual(vdc.status, VirtualDeviceContextStatusChoices.STATUS_PLANNED)
+
 
 class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = MACAddress

+ 5 - 2
netbox/ipam/views.py

@@ -1653,8 +1653,11 @@ class VLANView(generic.ObjectView):
                     actions.AddObject(
                         'ipam.prefix',
                         url_params={
-                            'tenant': lambda ctx: ctx['object'].tenant.pk if ctx['object'].tenant else None,
-                            'site': lambda ctx: ctx['object'].site.pk if ctx['object'].site else None,
+                            'tenant': lambda ctx: ctx['object'].tenant_id,
+                            'scope_type': lambda ctx: (
+                                ContentType.objects.get_for_model(Site).pk if ctx['object'].site_id else None
+                            ),
+                            'scope': lambda ctx: ctx['object'].site_id,
                             'vlan': lambda ctx: ctx['object'].pk,
                         },
                         label=_('Add a Prefix'),

+ 20 - 0
netbox/netbox/models/__init__.py

@@ -58,6 +58,7 @@ class BaseModel(models.Model):
     This class provides some important overrides to Django's default functionality, such as
     - Overriding the default manager to use RestrictedQuerySet
     - Extending `clean()` to validate GenericForeignKey fields
+    - Extending `clean()` and `save()` to coerce empty strings to None on unique nullable CharFields
     """
 
     objects = RestrictedQuerySet.as_manager()
@@ -65,11 +66,26 @@ class BaseModel(models.Model):
     class Meta:
         abstract = True
 
+    def _coerce_nullable_unique_chars(self):
+        """
+        Coerce empty strings to None on unique nullable CharFields to avoid spurious
+        uniqueness violations (PostgreSQL treats two empty strings as duplicates).
+        """
+        for field in self._meta.concrete_fields:
+            if (
+                isinstance(field, models.CharField)
+                and field.null
+                and field.unique
+                and getattr(self, field.attname, None) == ''
+            ):
+                setattr(self, field.attname, None)
+
     def clean(self):
         """
         Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
         """
         super().clean()
+        self._coerce_nullable_unique_chars()
 
         for field in self._meta.get_fields():
             if isinstance(field, GenericForeignKey):
@@ -97,6 +113,10 @@ class BaseModel(models.Model):
                     # update the GFK field value
                     setattr(self, field.name, obj)
 
+    def save(self, *args, **kwargs):
+        self._coerce_nullable_unique_chars()
+        super().save(*args, **kwargs)
+
 
 class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, EventRulesMixin, BaseModel):
     """

+ 9 - 5
netbox/netbox/ui/actions.py

@@ -92,14 +92,18 @@ class LinkAction(PanelAction):
         """
         url = reverse(self.view_name, kwargs=self.view_kwargs)
         if self.url_params:
-            # If the param value is callable, call it with the context and save the result.
-            url_params = {
-                k: v(context) if callable(v) else v for k, v in self.url_params.items()
-            }
+            url_params = {}
+            for key, value in self.url_params.items():
+                # If the param value is callable, call it with the context and save the result.
+                value = value(context) if callable(value) else value
+                # Omit parameters whose value resolved to None
+                if value is not None:
+                    url_params[key] = value
             # Set the return URL if not already set and an object is available.
             if 'return_url' not in url_params and 'object' in context:
                 url_params['return_url'] = context['object'].get_absolute_url()
-            url = f'{url}?{urlencode(url_params)}'
+            if url_params:
+                url = f'{url}?{urlencode(url_params)}'
         return url
 
     def get_context(self, context):

+ 4 - 0
netbox/virtualization/forms/bulk_edit.py

@@ -199,6 +199,10 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
+        # The ?device=<id> GET param is navigation context (filter), not an intent to change the
+        # device field — drop it from initial so Django's changed_data doesn't treat it as an edit.
+        self.initial.pop('device', None)
+
         # Set unit labels based on configured RAM_BASE_UNIT / DISK_BASE_UNIT (MB vs MiB)
         self.fields['memory'].label = _('Memory ({unit})').format(unit=get_capacity_unit_label(settings.RAM_BASE_UNIT))
         self.fields['disk'].label = _('Disk ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT))

+ 27 - 0
netbox/virtualization/tests/test_views.py

@@ -462,6 +462,33 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk})
         self.assertHttpStatus(self.client.get(url), 200)
 
+    def test_bulk_edit_device_context_preserves_device(self):
+        """
+        Regression test for #21990: Bulk editing VMs from the Device's VMs tab (URL contains
+        ?device=<id>) must not clear the device field on those VMs.
+        """
+        self.add_permissions('virtualization.view_virtualmachine', 'virtualization.change_virtualmachine')
+
+        device = VirtualMachine.objects.filter(device__isnull=False).first().device
+        vms = list(VirtualMachine.objects.filter(device=device)[:3])
+        pk_list = [vm.pk for vm in vms]
+
+        data = {
+            'pk': pk_list,
+            '_apply': True,
+            # Only change status — device is intentionally omitted
+            'status': VirtualMachineStatusChoices.STATUS_STAGED,
+        }
+
+        # Simulate navigation from Device -> Virtual Machines tab by passing ?device=<id> as GET param
+        url = reverse('virtualization:virtualmachine_bulk_edit') + f'?device={device.pk}'
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 302)
+
+        for vm in VirtualMachine.objects.filter(pk__in=pk_list):
+            self.assertEqual(vm.device, device, msg=f"Device was unexpectedly cleared on VM '{vm.name}'")
+            self.assertEqual(vm.status, VirtualMachineStatusChoices.STATUS_STAGED)
+
     def test_virtualmachine_renderconfig(self):
         configtemplate = ConfigTemplate.objects.create(
             name='Test Config Template',

+ 5 - 5
requirements.txt

@@ -17,7 +17,7 @@ django-taggit==6.1.0
 django-timezone-field==7.2.1
 djangorestframework==3.17.1
 drf-spectacular==0.29.0
-drf-spectacular-sidecar==2026.4.14
+drf-spectacular-sidecar==2026.5.1
 feedparser==6.0.12
 gunicorn==25.3.0
 Jinja2==3.1.6
@@ -30,15 +30,15 @@ mkdocstrings-python==2.0.3
 netaddr==1.3.0
 nh3==0.3.5
 Pillow==12.2.0
-psycopg[c,pool]==3.3.3
+psycopg[c,pool]==3.3.4
 PyYAML==6.0.3
 requests==2.33.1
 rq==2.8.0
-social-auth-app-django==5.8.0
+social-auth-app-django==5.9.0
 social-auth-core==4.8.7
 sorl-thumbnail==13.0.0
-strawberry-graphql==0.315.2
-strawberry-graphql-django==0.82.1
+strawberry-graphql==0.315.3
+strawberry-graphql-django==0.84.0
 svgwrite==1.4.3
 tablib==3.9.0
 tzdata==2026.2

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