Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions
1e297d55ee Update source translation strings
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
CI / build (20.x, 3.14) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
2026-01-16 05:04:49 +00:00
bctiemann
fdb987ef91 Merge pull request #21183 from netbox-community/21178-change-rack-dimensions-display-to-be-more-consistent
Fixes #21178: Add spacing in mounting depth format string
2026-01-15 17:48:39 -05:00
bctiemann
b5a23db43c Merge pull request #21164 from netbox-community/21118-site
fix performance regression for Site save, use bulk_update for cached fields
2026-01-15 17:48:01 -05:00
bctiemann
366b69aff7 Merge pull request #21143 from netbox-community/21050-device-oob-ip-may-become-orphaned
Fixes #21050: Prevent reassignment of OOB IPs
2026-01-15 17:47:00 -05:00
bctiemann
c3e8c5e69c Merge pull request #21100 from netbox-community/21097-graphql-id-lookups
Fixes #21097: Fix comparison lookups for ID filters in GraphQL API
2026-01-15 17:44:22 -05:00
adionit7
b55f36469d Update CodeQL Action from v3 to v4
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
- Update github/codeql-action/init from @v3 to @v4
- Update github/codeql-action/analyze from @v3 to @v4

Fixes #21156
2026-01-15 16:46:25 -05:00
Martin Hauser
1c46215cd5 feat(extras): Allow updates to data_source and data_file via API
Adds support for PATCHing ConfigContext and ConfigContextProfile with
integer IDs for `data_source` and `data_file`.
Adds regression tests to validate assignment and API functionality.

Fixes #20933
2026-01-15 14:37:16 -05:00
Martin Hauser
7fded2fd87 fix(dcim): Add spacing in mounting depth format string
Corrects the format string for mounting depth to include a space
between the value and the unit (`mm`) for consistency with other
measurements.

Fixes #21178
2026-01-15 18:52:25 +01:00
Martin Hauser
0ddc5805c4 fix(core): Use gettext_lazy in data.py
Replace `gettext()` with `gettext_lazy()` to avoid locale-dependent
model serialization (and false-positive pending migration warnings).
Also make a missing `ValidationError` message translatable and
format-safe.

Fixes #21175
2026-01-15 12:47:05 -05:00
github-actions
c1bbc026e2 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-15 05:05:36 +00:00
Arthur
8cbfe94fba fix performance regression for Site save, use bulk_update for cached fields 2026-01-14 16:30:40 -08:00
Jason Novinger
434334d927 Fixes #20239: Prevent shared mutable state in PluginMenuItem and PluginMenuButton (#21099)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
PluginMenuItem and PluginMenuButton classes used mutable class-level
defaults for `permissions` and `buttons` attributes, causing permission
leakage between instances when these attributes were modified without
explicit parameters.

Changed to initialize these attributes as fresh lists per instance in
__init__ when not explicitly provided, following standard Python pattern
for avoiding mutable default arguments.
2026-01-14 12:50:35 -08:00
Jeremy Stretch
6bd083b7ed Closes #21142: Enable filtering device components by site/location/rack directly via GraphQL API (#21145) 2026-01-14 08:06:55 -06:00
bctiemann
f38faf2e01 Merge pull request #21135 from netbox-community/21102-fix-graphiql-explorer
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Fixes #21102: Fix GraphiQL explorer UI
2026-01-13 12:33:58 -05:00
Martin Hauser
f4892caa51 fix(ipam): Prevent reassignment of OOB IPs
Disable reassignment of IP addresses designated as primary or OOB for
parent objects. Adds validation to block changes when an IP is marked as
the OOB IP.

Fixes #21050
2026-01-13 18:13:31 +01:00
Mark Robert Coleman
e60807adc5 Fixes #21121: Expand changelog message doc/add cross-references (#21138) 2026-01-13 09:58:06 -06:00
github-actions
e14934e5a5 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-13 05:05:43 +00:00
Adam
ae03723e43 Fixes #21105: Update help text for token field on API page. (#21106)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2026-01-12 19:17:35 -06:00
Jeremy Stretch
c0f79df91f Introduce a new issue type for feature removals (#21092)
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2026-01-12 15:41:25 -06:00
Jeremy Stretch
edbfd0bae6 Fixes #21117: Avoid exception when attempting to create v2 token without API_TOKEN_PEPPERS defined (#21132) 2026-01-12 15:40:42 -06:00
Jeremy Stretch
a54ad24b47 Fixes #21097: Fix comparison lookups for ID filters in GraphQL API 2026-01-08 16:34:13 -05:00
21 changed files with 277 additions and 60 deletions

View File

@@ -1,20 +1,26 @@
--- ---
name: 🗑 Deprecation name: Deprecation
type: Deprecation type: Deprecation
description: The removal of an existing feature or resource description: Designation of a feature or behavior that will be removed in a future release
labels: ["netbox", "type: deprecation"] labels: ["netbox", "type: deprecation"]
body: body:
- type: textarea - type: textarea
attributes: attributes:
label: Proposed Changes label: Deprecated Functionality
description: > description: >
Describe in detail the proposed changes. What is being removed? Describe the feature(s) and/or behavior that is being flagged for deprecation.
validations:
required: true
- type: input
attributes:
label: Scheduled removal
description: In what future release will the deprecated functionality be removed?
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Justification label: Justification
description: Please provide justification for the proposed change(s). description: Please provide justification for the deprecation.
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@@ -0,0 +1,20 @@
---
name: 🗑️ Feature Removal
type: Removal
description: The removal of a deprecated feature or resource
labels: ["netbox", "type: removal"]
body:
- type: input
attributes:
label: Deprecation Issue
description: Specify the issue in which this deprecation was announced.
placeholder: "#1234"
validations:
required: true
- type: textarea
attributes:
label: Summary of Changes
description: >
List all changes necessary to remove the deprecated feature or resource.
validations:
required: true

View File

@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v4
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@@ -10,9 +10,11 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
## User Messages ## User Messages
!!! info "This feature was introduced in NetBox v4.4." When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message (up to 200 characters) that will appear in the change record. This can be helpful to capture additional context, such as the reason for a change or a reference to an external ticket.
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change. When editing an object via the web UI, the "Changelog message" field appears at the bottom of the form. This field is optional. The changelog message field is available in object create forms, object edit forms, delete confirmation dialogs, and bulk operations.
For information on including changelog messages when making changes via the REST API, see [Changelog Messages](../integrations/rest-api.md#changelog-messages).
## Correlating Changes by Request ## Correlating Changes by Request

View File

@@ -610,9 +610,7 @@ http://netbox/api/dcim/sites/ \
## Changelog Messages ## Changelog Messages
!!! info "This feature was introduced in NetBox v4.4." Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
For example, the following API request will create a new site and record a message in the resulting changelog entry: For example, the following API request will create a new site and record a message in the resulting changelog entry:
@@ -628,7 +626,7 @@ http://netbox/api/dcim/sites/ \
}' }'
``` ```
This approach works when creating, modifying, or deleting objects, either individually or in bulk. This approach works when creating, modifying, or deleting objects, either individually or in bulk. For more information about change logging, see [Change Logging](../features/change-logging.md).
## Uploading Files ## Uploading Files

View File

@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', 'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
] ]
brief_fields = ('id', 'url', 'display', 'path') brief_fields = ('id', 'url', 'display', 'path')
read_only_fields = ['path', 'last_updated', 'size', 'hash']

View File

@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
@@ -128,7 +128,9 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type # Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''): if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({ raise ValidationError({
'source_url': "URLs for local sources must start with file:// (or specify no scheme)" 'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
scheme='file://'
)
}) })
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -38,6 +38,15 @@ class ScopedFilterMixin:
@dataclass @dataclass
class ComponentModelFilterMixin: class ComponentModelFilterMixin:
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site')
)
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rack')
)
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -211,12 +211,16 @@ def sync_cached_scope_fields(instance, created, **kwargs):
for model in (Prefix, Cluster, WirelessLAN): for model in (Prefix, Cluster, WirelessLAN):
qs = model.objects.filter(**filters) qs = model.objects.filter(**filters)
# Bulk update cached fields to avoid O(N) performance issues with large datasets.
# This does not trigger post_save signals, avoiding spurious change log entries.
objects_to_update = []
for obj in qs: for obj in qs:
# Recompute cache using the same logic as save() # Recompute cache using the same logic as save()
obj.cache_related_objects() obj.cache_related_objects()
obj.save(update_fields=[ objects_to_update.append(obj)
'_location',
'_site', if objects_to_update:
'_site_group', model.objects.bulk_update(
'_region', objects_to_update,
]) ['_location', '_site', '_site_group', '_region']
)

View File

@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
) )
data_file = DataFileSerializer( data_file = DataFileSerializer(
nested=True, nested=True,
read_only=True required=False
) )
class Meta: class Meta:
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
) )
data_file = DataFileSerializer( data_file = DataFileSerializer(
nested=True, nested=True,
read_only=True required=False
) )
class Meta: class Meta:

View File

@@ -1,4 +1,5 @@
import datetime import datetime
import hashlib
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@@ -7,7 +8,7 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from core.events import * from core.events import *
from core.models import ObjectType from core.models import DataFile, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
@@ -731,6 +732,51 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
) )
ConfigContextProfile.objects.bulk_create(profiles) ConfigContextProfile.objects.bulk_create(profiles)
def test_update_data_source_and_data_file(self):
"""
Regression test: Ensure data_source and data_file can be assigned via the API.
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
"""
self.add_permissions(
'core.view_datafile',
'core.view_datasource',
'extras.view_configcontextprofile',
'extras.change_configcontextprofile',
)
config_context_profile = ConfigContextProfile.objects.first()
# Create a data source and file
datasource = DataSource.objects.create(
name='Data Source 1',
type='local',
source_url='file:///tmp/netbox-datasource/',
)
# Generate a valid dummy YAML file
file_data = b'profile: configcontext\n'
datafile = DataFile.objects.create(
source=datasource,
path='dir1/file1.yml',
last_updated=now(),
size=len(file_data),
hash=hashlib.sha256(file_data).hexdigest(),
data=file_data,
)
url = self._get_detail_url(config_context_profile)
payload = {
'data_source': datasource.pk,
'data_file': datafile.pk,
}
response = self.client.patch(url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
config_context_profile.refresh_from_db()
self.assertEqual(config_context_profile.data_source_id, datasource.pk)
self.assertEqual(config_context_profile.data_file_id, datafile.pk)
self.assertEqual(response.data['data_source']['id'], datasource.pk)
self.assertEqual(response.data['data_file']['id'], datafile.pk)
class ConfigContextTest(APIViewTestCases.APIViewTestCase): class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext model = ConfigContext
@@ -812,6 +858,51 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
rendered_context = device.get_config_context() rendered_context = device.get_config_context()
self.assertEqual(rendered_context['bar'], 456) self.assertEqual(rendered_context['bar'], 456)
def test_update_data_source_and_data_file(self):
"""
Regression test: Ensure data_source and data_file can be assigned via the API.
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
"""
self.add_permissions(
'core.view_datafile',
'core.view_datasource',
'extras.view_configcontext',
'extras.change_configcontext',
)
config_context = ConfigContext.objects.first()
# Create a data source and file
datasource = DataSource.objects.create(
name='Data Source 1',
type='local',
source_url='file:///tmp/netbox-datasource/',
)
# Generate a valid dummy YAML file
file_data = b'context: config\n'
datafile = DataFile.objects.create(
source=datasource,
path='dir1/file1.yml',
last_updated=now(),
size=len(file_data),
hash=hashlib.sha256(file_data).hexdigest(),
data=file_data,
)
url = self._get_detail_url(config_context)
payload = {
'data_source': datasource.pk,
'data_file': datafile.pk,
}
response = self.client.patch(url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
config_context.refresh_from_db()
self.assertEqual(config_context.data_source_id, datasource.pk)
self.assertEqual(config_context.data_file_id, datafile.pk)
self.assertEqual(response.data['data_source']['id'], datasource.pk)
self.assertEqual(response.data['data_file']['id'], datafile.pk)
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase): class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConfigTemplate model = ConfigTemplate

View File

@@ -372,8 +372,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
'virtual_machine_id': instance.assigned_object.virtual_machine.pk, 'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
}) })
# Disable object assignment fields if the IP address is designated as primary # Disable object assignment fields if the IP address is designated as primary or OOB
if self.initial.get('primary_for_parent'): if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'):
self.fields['interface'].disabled = True self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True self.fields['vminterface'].disabled = True
self.fields['fhrpgroup'].disabled = True self.fields['fhrpgroup'].disabled = True

View File

@@ -940,6 +940,13 @@ class IPAddress(ContactsMixin, PrimaryModel):
_("Cannot reassign IP address while it is designated as the primary IP for the parent object") _("Cannot reassign IP address while it is designated as the primary IP for the parent object")
) )
# can't use is_oob_ip as self.assigned_object might be changed
if hasattr(original_parent, 'oob_ip') and original_parent.oob_ip_id == self.pk:
if parent != original_parent:
raise ValidationError(
_("Cannot reassign IP address while it is designated as the OOB IP for the parent object")
)
# Validate IP status selection # Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({ raise ValidationError({

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import strawberry_django import strawberry_django
from strawberry import ID from strawberry import ID
from strawberry_django import FilterLookup from strawberry_django import ComparisonFilterLookup, FilterLookup
from core.graphql.filter_mixins import ChangeLoggingMixin from core.graphql.filter_mixins import ChangeLoggingMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
@@ -23,7 +23,7 @@ __all__ = (
@dataclass @dataclass
class BaseModelFilter: class BaseModelFilter:
id: FilterLookup[ID] | None = strawberry_django.filter_field() id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter): class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):

View File

@@ -37,8 +37,6 @@ class PluginMenuItem:
Alternatively, a pre-generated url can be set on the object which will be rendered literally. Alternatively, a pre-generated url can be set on the object which will be rendered literally.
Buttons are each specified as a list of PluginMenuButton instances. Buttons are each specified as a list of PluginMenuButton instances.
""" """
permissions = []
buttons = []
_url = None _url = None
def __init__( def __init__(
@@ -54,10 +52,14 @@ class PluginMenuItem:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions self.permissions = permissions
else:
self.permissions = []
if buttons is not None: if buttons is not None:
if type(buttons) not in (list, tuple): if type(buttons) not in (list, tuple):
raise TypeError(_("Buttons must be passed as a tuple or list.")) raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons self.buttons = buttons
else:
self.buttons = []
@property @property
def url(self): def url(self):
@@ -74,7 +76,6 @@ class PluginMenuButton:
ButtonColorChoices. ButtonColorChoices.
""" """
color = ButtonColorChoices.DEFAULT color = ButtonColorChoices.DEFAULT
permissions = []
_url = None _url = None
def __init__(self, link, title, icon_class, color=None, permissions=None): def __init__(self, link, title, icon_class, color=None, permissions=None):
@@ -87,6 +88,8 @@ class PluginMenuButton:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions self.permissions = permissions
else:
self.permissions = []
if color is not None: if color is not None:
if color not in ButtonColorChoices.values(): if color not in ButtonColorChoices.values():
raise ValueError(_("Button color must be a choice within ButtonColorChoices.")) raise ValueError(_("Button color must be a choice within ButtonColorChoices."))

View File

@@ -11,7 +11,7 @@ from netbox.tests.dummy_plugin import config as dummy_config
from netbox.tests.dummy_plugin.data_backends import DummyBackend from netbox.tests.dummy_plugin.data_backends import DummyBackend
from netbox.tests.dummy_plugin.jobs import DummySystemJob from netbox.tests.dummy_plugin.jobs import DummySystemJob
from netbox.tests.dummy_plugin.webhook_callbacks import set_context from netbox.tests.dummy_plugin.webhook_callbacks import set_context
from netbox.plugins.navigation import PluginMenu from netbox.plugins.navigation import PluginMenu, PluginMenuItem, PluginMenuButton
from netbox.plugins.utils import get_plugin_config from netbox.plugins.utils import get_plugin_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
from netbox.registry import registry from netbox.registry import registry
@@ -227,3 +227,46 @@ class PluginTest(TestCase):
Test the registration of webhook callbacks. Test the registration of webhook callbacks.
""" """
self.assertIn(set_context, registry['webhook_callbacks']) self.assertIn(set_context, registry['webhook_callbacks'])
class PluginNavigationTest(TestCase):
def test_plugin_menu_item_independent_permissions(self):
item1 = PluginMenuItem(link='test1', link_text='Test 1')
item1.permissions.append('leaked_permission')
item2 = PluginMenuItem(link='test2', link_text='Test 2')
self.assertIsNot(item1.permissions, item2.permissions)
self.assertEqual(item1.permissions, ['leaked_permission'])
self.assertEqual(item2.permissions, [])
def test_plugin_menu_item_independent_buttons(self):
item1 = PluginMenuItem(link='test1', link_text='Test 1')
button = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
item1.buttons.append(button)
item2 = PluginMenuItem(link='test2', link_text='Test 2')
self.assertIsNot(item1.buttons, item2.buttons)
self.assertEqual(len(item1.buttons), 1)
self.assertEqual(item1.buttons[0], button)
self.assertEqual(item2.buttons, [])
def test_plugin_menu_button_independent_permissions(self):
button1 = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
button1.permissions.append('leaked_permission')
button2 = PluginMenuButton(link='button2', title='Button 2', icon_class='mdi-test')
self.assertIsNot(button1.permissions, button2.permissions)
self.assertEqual(button1.permissions, ['leaked_permission'])
self.assertEqual(button2.permissions, [])
def test_explicit_permissions_remain_independent(self):
item1 = PluginMenuItem(link='test1', link_text='Test 1', permissions=['explicit_permission'])
item2 = PluginMenuItem(link='test2', link_text='Test 2', permissions=['different_permission'])
self.assertIsNot(item1.permissions, item2.permissions)
self.assertEqual(item1.permissions, ['explicit_permission'])
self.assertEqual(item2.permissions, ['different_permission'])

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 05:04+0000\n" "POT-Creation-Date: 2026-01-16 05:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1822,7 +1822,6 @@ msgid "ASN Count"
msgstr "" msgstr ""
#: netbox/circuits/tables/virtual_circuits.py:64 #: netbox/circuits/tables/virtual_circuits.py:64
#: netbox/netbox/navigation/menu.py:235
#: netbox/templates/circuits/virtualcircuit.html:87 #: netbox/templates/circuits/virtualcircuit.html:87
#: netbox/templates/vpn/l2vpn.html:60 netbox/templates/vpn/tunnel.html:72 #: netbox/templates/vpn/l2vpn.html:60 netbox/templates/vpn/tunnel.html:72
#: netbox/vpn/tables/tunnels.py:59 #: netbox/vpn/tables/tunnels.py:59
@@ -2436,7 +2435,7 @@ msgstr ""
msgid "Change logging is not supported for this object type ({type})." msgid "Change logging is not supported for this object type ({type})."
msgstr "" msgstr ""
#: netbox/core/models/config.py:21 netbox/core/models/data.py:282 #: netbox/core/models/config.py:21 netbox/core/models/data.py:284
#: netbox/core/models/files.py:29 netbox/core/models/jobs.py:60 #: netbox/core/models/files.py:29 netbox/core/models/jobs.py:60
#: netbox/extras/models/models.py:847 netbox/extras/models/notifications.py:39 #: netbox/extras/models/models.py:847 netbox/extras/models/notifications.py:39
#: netbox/extras/models/notifications.py:195 #: netbox/extras/models/notifications.py:195
@@ -2542,58 +2541,63 @@ msgstr ""
msgid "Unknown backend type: {type}" msgid "Unknown backend type: {type}"
msgstr "" msgstr ""
#: netbox/core/models/data.py:180 #: netbox/core/models/data.py:131
#, python-brace-format
msgid "URLs for local sources must start with {scheme} (or specify no scheme)"
msgstr ""
#: netbox/core/models/data.py:182
msgid "Cannot initiate sync; syncing already in progress." msgid "Cannot initiate sync; syncing already in progress."
msgstr "" msgstr ""
#: netbox/core/models/data.py:193 #: netbox/core/models/data.py:195
msgid "" msgid ""
"There was an error initializing the backend. A dependency needs to be " "There was an error initializing the backend. A dependency needs to be "
"installed: " "installed: "
msgstr "" msgstr ""
#: netbox/core/models/data.py:286 netbox/core/models/files.py:33 #: netbox/core/models/data.py:288 netbox/core/models/files.py:33
#: netbox/netbox/models/features.py:67 #: netbox/netbox/models/features.py:67
msgid "last updated" msgid "last updated"
msgstr "" msgstr ""
#: netbox/core/models/data.py:296 netbox/dcim/models/cables.py:622 #: netbox/core/models/data.py:298 netbox/dcim/models/cables.py:622
msgid "path" msgid "path"
msgstr "" msgstr ""
#: netbox/core/models/data.py:299 #: netbox/core/models/data.py:301
msgid "File path relative to the data source's root" msgid "File path relative to the data source's root"
msgstr "" msgstr ""
#: netbox/core/models/data.py:303 netbox/ipam/models/ip.py:507 #: netbox/core/models/data.py:305 netbox/ipam/models/ip.py:507
msgid "size" msgid "size"
msgstr "" msgstr ""
#: netbox/core/models/data.py:306 #: netbox/core/models/data.py:308
msgid "hash" msgid "hash"
msgstr "" msgstr ""
#: netbox/core/models/data.py:310 #: netbox/core/models/data.py:312
msgid "Length must be 64 hexadecimal characters." msgid "Length must be 64 hexadecimal characters."
msgstr "" msgstr ""
#: netbox/core/models/data.py:312 #: netbox/core/models/data.py:314
msgid "SHA256 hash of the file data" msgid "SHA256 hash of the file data"
msgstr "" msgstr ""
#: netbox/core/models/data.py:326 #: netbox/core/models/data.py:328
msgid "data file" msgid "data file"
msgstr "" msgstr ""
#: netbox/core/models/data.py:327 #: netbox/core/models/data.py:329
msgid "data files" msgid "data files"
msgstr "" msgstr ""
#: netbox/core/models/data.py:400 #: netbox/core/models/data.py:402
msgid "auto sync record" msgid "auto sync record"
msgstr "" msgstr ""
#: netbox/core/models/data.py:401 #: netbox/core/models/data.py:403
msgid "auto sync records" msgid "auto sync records"
msgstr "" msgstr ""
@@ -11241,7 +11245,13 @@ msgid ""
"parent object" "parent object"
msgstr "" msgstr ""
#: netbox/ipam/models/ip.py:946 #: netbox/ipam/models/ip.py:947
msgid ""
"Cannot reassign IP address while it is designated as the OOB IP for the "
"parent object"
msgstr ""
#: netbox/ipam/models/ip.py:953
msgid "Only IPv6 addresses can be assigned SLAAC status" msgid "Only IPv6 addresses can be assigned SLAAC status"
msgstr "" msgstr ""
@@ -12190,6 +12200,10 @@ msgstr ""
msgid "L2VPNs" msgid "L2VPNs"
msgstr "" msgstr ""
#: netbox/netbox/navigation/menu.py:235
msgid "L2VPN Terminations"
msgstr ""
#: netbox/netbox/navigation/menu.py:241 #: netbox/netbox/navigation/menu.py:241
msgid "IKE Proposals" msgid "IKE Proposals"
msgstr "" msgstr ""
@@ -12486,8 +12500,8 @@ msgstr ""
msgid "Delete Selected" msgid "Delete Selected"
msgstr "" msgstr ""
#: netbox/netbox/plugins/navigation.py:55 #: netbox/netbox/plugins/navigation.py:53
#: netbox/netbox/plugins/navigation.py:88 #: netbox/netbox/plugins/navigation.py:89
msgid "Permissions must be passed as a tuple or list." msgid "Permissions must be passed as a tuple or list."
msgstr "" msgstr ""
@@ -12495,7 +12509,7 @@ msgstr ""
msgid "Buttons must be passed as a tuple or list." msgid "Buttons must be passed as a tuple or list."
msgstr "" msgstr ""
#: netbox/netbox/plugins/navigation.py:92 #: netbox/netbox/plugins/navigation.py:95
msgid "Button color must be a choice within ButtonColorChoices." msgid "Button color must be a choice within ButtonColorChoices."
msgstr "" msgstr ""
@@ -15938,7 +15952,7 @@ msgstr ""
#: netbox/users/forms/model_forms.py:126 #: netbox/users/forms/model_forms.py:126
msgid "" msgid ""
"Tokens must be at least 40 characters in length. <strong>Be sure to record " "Tokens must be at least 40 characters in length. <strong>Be sure to record "
"your key</strong> prior to submitting this form, as it will no longer be " "your token</strong> prior to submitting this form, as it will no longer be "
"accessible once the token has been created." "accessible once the token has been created."
msgstr "" msgstr ""
@@ -16077,7 +16091,7 @@ msgid "write enabled"
msgstr "" msgstr ""
#: netbox/users/models/tokens.py:72 #: netbox/users/models/tokens.py:72
msgid "Permit create/update/delete operations using this key" msgid "Permit create/update/delete operations using this token"
msgstr "" msgstr ""
#: netbox/users/models/tokens.py:76 #: netbox/users/models/tokens.py:76
@@ -16126,12 +16140,16 @@ msgstr ""
msgid "tokens" msgid "tokens"
msgstr "" msgstr ""
#: netbox/users/models/tokens.py:219 #: netbox/users/models/tokens.py:217
msgid "Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined."
msgstr ""
#: netbox/users/models/tokens.py:222
#, python-brace-format #, python-brace-format
msgid "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." msgid "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS."
msgstr "" msgstr ""
#: netbox/users/models/tokens.py:232 #: netbox/users/models/tokens.py:235
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"Expiration time must be in the future. Current server time is {current_time} " "Expiration time must be in the future. Current server time is {current_time} "

View File

@@ -123,7 +123,7 @@ class UserTokenForm(forms.ModelForm):
token = forms.CharField( token = forms.CharField(
label=_('Token'), label=_('Token'),
help_text=_( help_text=_(
'Tokens must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to ' 'Tokens must be at least 40 characters in length. <strong>Be sure to record your token</strong> prior to '
'submitting this form, as it will no longer be accessible once the token has been created.' 'submitting this form, as it will no longer be accessible once the token has been created.'
), ),
widget=forms.TextInput( widget=forms.TextInput(

View File

@@ -69,7 +69,7 @@ class Token(models.Model):
write_enabled = models.BooleanField( write_enabled = models.BooleanField(
verbose_name=_('write enabled'), verbose_name=_('write enabled'),
default=True, default=True,
help_text=_('Permit create/update/delete operations using this key') help_text=_('Permit create/update/delete operations using this token')
) )
# For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2. # For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2.
plaintext = models.CharField( plaintext = models.CharField(
@@ -213,6 +213,9 @@ class Token(models.Model):
def clean(self): def clean(self):
super().clean() super().clean()
if self.version == TokenVersionChoices.V2 and not settings.API_TOKEN_PEPPERS:
raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined."))
if self._state.adding: if self._state.adding:
if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
raise ValidationError(_( raise ValidationError(_(

View File

@@ -1,9 +1,10 @@
from datetime import timedelta from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from users.choices import TokenVersionChoices
from users.models import User, Token from users.models import User, Token
from utilities.testing import create_test_user from utilities.testing import create_test_user
@@ -94,6 +95,15 @@ class TokenTest(TestCase):
token.refresh_from_db() token.refresh_from_db()
self.assertEqual(token.description, 'New Description') self.assertEqual(token.description, 'New Description')
@override_settings(API_TOKEN_PEPPERS={})
def test_v2_without_peppers_configured(self):
"""
Attempting to save a v2 token without API_TOKEN_PEPPERS defined should raise a ValidationError.
"""
token = Token(version=TokenVersionChoices.V2)
with self.assertRaises(ValidationError):
token.clean()
class UserConfigTest(TestCase): class UserConfigTest(TestCase):