Compare commits

..

1 Commits

Author SHA1 Message Date
Jeremy Stretch
bda369623c Fixes #21140: Ensure default panel attribute labels are translated 2026-01-14 08:56:50 -05:00
15 changed files with 42 additions and 213 deletions

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@v4 uses: github/codeql-action/init@v3
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@v4 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@@ -44,4 +44,3 @@ 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_lazy as _ from django.utils.translation import gettext 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,9 +128,7 @@ 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 {scheme} (or specify no scheme)").format( 'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
scheme='file://'
)
}) })
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -38,15 +38,6 @@ 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,16 +211,12 @@ 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()
objects_to_update.append(obj) obj.save(update_fields=[
'_location',
if objects_to_update: '_site',
model.objects.bulk_update( '_site_group',
objects_to_update, '_region',
['_location', '_site', '_site_group', '_region'] ])
)

View File

@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{} mm') mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
class RackNumberingPanel(panels.ObjectAttributesPanel): class RackNumberingPanel(panels.ObjectAttributesPanel):

View File

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

View File

@@ -1,5 +1,4 @@
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
@@ -8,7 +7,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 DataFile, DataSource, ObjectType from core.models import 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 *
@@ -732,51 +731,6 @@ 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
@@ -858,51 +812,6 @@ 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 or OOB # Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'): if self.initial.get('primary_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,13 +940,6 @@ 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 ComparisonFilterLookup, FilterLookup from strawberry_django import 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: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field() id: FilterLookup[ID] | None = strawberry_django.filter_field()
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter): class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):

View File

@@ -37,6 +37,8 @@ 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__(
@@ -52,14 +54,10 @@ 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):
@@ -76,6 +74,7 @@ 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):
@@ -88,8 +87,6 @@ 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, PluginMenuItem, PluginMenuButton from netbox.plugins.navigation import PluginMenu
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,46 +227,3 @@ 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

@@ -164,7 +164,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
""" """
label = name[:1].upper() + name[1:] label = name[:1].upper() + name[1:]
label = label.replace('_', ' ') label = label.replace('_', ' ')
return label return _(label)
def get_context(self, context): def get_context(self, context):
# Determine which attributes to display in the panel based on only/exclude args # Determine which attributes to display in the panel based on only/exclude args

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-16 05:04+0000\n" "POT-Creation-Date: 2026-01-13 05:05+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"
@@ -2435,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:284 #: netbox/core/models/config.py:21 netbox/core/models/data.py:282
#: 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
@@ -2541,63 +2541,58 @@ msgstr ""
msgid "Unknown backend type: {type}" msgid "Unknown backend type: {type}"
msgstr "" msgstr ""
#: netbox/core/models/data.py:131 #: netbox/core/models/data.py:180
#, 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:195 #: netbox/core/models/data.py:193
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:288 netbox/core/models/files.py:33 #: netbox/core/models/data.py:286 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:298 netbox/dcim/models/cables.py:622 #: netbox/core/models/data.py:296 netbox/dcim/models/cables.py:622
msgid "path" msgid "path"
msgstr "" msgstr ""
#: netbox/core/models/data.py:301 #: netbox/core/models/data.py:299
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:305 netbox/ipam/models/ip.py:507 #: netbox/core/models/data.py:303 netbox/ipam/models/ip.py:507
msgid "size" msgid "size"
msgstr "" msgstr ""
#: netbox/core/models/data.py:308 #: netbox/core/models/data.py:306
msgid "hash" msgid "hash"
msgstr "" msgstr ""
#: netbox/core/models/data.py:312 #: netbox/core/models/data.py:310
msgid "Length must be 64 hexadecimal characters." msgid "Length must be 64 hexadecimal characters."
msgstr "" msgstr ""
#: netbox/core/models/data.py:314 #: netbox/core/models/data.py:312
msgid "SHA256 hash of the file data" msgid "SHA256 hash of the file data"
msgstr "" msgstr ""
#: netbox/core/models/data.py:328 #: netbox/core/models/data.py:326
msgid "data file" msgid "data file"
msgstr "" msgstr ""
#: netbox/core/models/data.py:329 #: netbox/core/models/data.py:327
msgid "data files" msgid "data files"
msgstr "" msgstr ""
#: netbox/core/models/data.py:402 #: netbox/core/models/data.py:400
msgid "auto sync record" msgid "auto sync record"
msgstr "" msgstr ""
#: netbox/core/models/data.py:403 #: netbox/core/models/data.py:401
msgid "auto sync records" msgid "auto sync records"
msgstr "" msgstr ""
@@ -11245,13 +11240,7 @@ msgid ""
"parent object" "parent object"
msgstr "" msgstr ""
#: netbox/ipam/models/ip.py:947 #: netbox/ipam/models/ip.py:946
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 ""
@@ -12500,8 +12489,8 @@ msgstr ""
msgid "Delete Selected" msgid "Delete Selected"
msgstr "" msgstr ""
#: netbox/netbox/plugins/navigation.py:53 #: netbox/netbox/plugins/navigation.py:55
#: netbox/netbox/plugins/navigation.py:89 #: netbox/netbox/plugins/navigation.py:88
msgid "Permissions must be passed as a tuple or list." msgid "Permissions must be passed as a tuple or list."
msgstr "" msgstr ""
@@ -12509,7 +12498,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:95 #: netbox/netbox/plugins/navigation.py:92
msgid "Button color must be a choice within ButtonColorChoices." msgid "Button color must be a choice within ButtonColorChoices."
msgstr "" msgstr ""