Merge branch 'main' into feature
This commit is contained in:
commit
28a5c7d882
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7285
contrib/openapi.json
7285
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue