Merge branch 'main' into feature

This commit is contained in:
Jeremy Stretch 2026-05-04 13:02:42 -04:00
commit 28a5c7d882
21 changed files with 377 additions and 7319 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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 }}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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(),

View File

@ -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

View File

@ -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'),

View File

@ -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):
"""

View File

@ -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):

View File

@ -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))

View File

@ -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',

View File

@ -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