mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-15 16:22:18 -06:00
Compare commits
9 Commits
21050-devi
...
21039-avif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc7c4af9d | ||
|
|
1c46215cd5 | ||
|
|
0ddc5805c4 | ||
|
|
c1bbc026e2 | ||
|
|
434334d927 | ||
|
|
6bd083b7ed | ||
|
|
f38faf2e01 | ||
|
|
e60807adc5 | ||
|
|
c3e111c769 |
@@ -10,9 +10,11 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -610,9 +610,7 @@ http://netbox/api/dcim/sites/ \
|
||||
|
||||
## 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. 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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
read_only_fields = ['path', 'last_updated', 'size', 'hash']
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
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.models import PrimaryModel
|
||||
@@ -128,7 +128,9 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||
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):
|
||||
|
||||
@@ -38,6 +38,15 @@ class ScopedFilterMixin:
|
||||
|
||||
@dataclass
|
||||
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_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -22,7 +22,7 @@ from utilities.forms.fields import (
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, ObjectAttribute
|
||||
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
|
||||
from utilities.forms.widgets import ChoicesWidget, ClearableFileInput, HTMXSelect
|
||||
from utilities.tables import get_table_for_model
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@@ -784,6 +784,10 @@ class ImageAttachmentForm(forms.ModelForm):
|
||||
fields = [
|
||||
'image', 'name', 'description',
|
||||
]
|
||||
# Explicitly set 'image/avif' to support AVIF selection in Firefox
|
||||
widgets = {
|
||||
'image': ClearableFileInput(attrs={'accept': 'image/*,image/avif'}),
|
||||
}
|
||||
help_texts = {
|
||||
'name': _("If no name is specified, the file name will be used.")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@@ -7,7 +8,7 @@ from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
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 extras.choices import *
|
||||
from extras.models import *
|
||||
@@ -731,6 +732,51 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
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):
|
||||
model = ConfigContext
|
||||
@@ -812,6 +858,51 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
rendered_context = device.get_config_context()
|
||||
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):
|
||||
model = ConfigTemplate
|
||||
|
||||
@@ -78,7 +78,7 @@ def image_upload(instance, filename):
|
||||
"""
|
||||
upload_dir = 'image-attachments'
|
||||
default_filename = 'unnamed'
|
||||
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
||||
allowed_img_extensions = ('avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
||||
|
||||
# Normalize Windows paths and create a Path object.
|
||||
normalized_filename = str(filename).replace('\\', '/')
|
||||
|
||||
@@ -372,8 +372,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
|
||||
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
|
||||
})
|
||||
|
||||
# Disable object assignment fields if the IP address is designated as primary or OOB
|
||||
if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'):
|
||||
# Disable object assignment fields if the IP address is designated as primary
|
||||
if self.initial.get('primary_for_parent'):
|
||||
self.fields['interface'].disabled = True
|
||||
self.fields['vminterface'].disabled = True
|
||||
self.fields['fhrpgroup'].disabled = True
|
||||
|
||||
@@ -940,13 +940,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
_("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
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -37,8 +37,6 @@ class PluginMenuItem:
|
||||
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.
|
||||
"""
|
||||
permissions = []
|
||||
buttons = []
|
||||
_url = None
|
||||
|
||||
def __init__(
|
||||
@@ -54,10 +52,14 @@ class PluginMenuItem:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if buttons is not None:
|
||||
if type(buttons) not in (list, tuple):
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
else:
|
||||
self.buttons = []
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
@@ -74,7 +76,6 @@ class PluginMenuButton:
|
||||
ButtonColorChoices.
|
||||
"""
|
||||
color = ButtonColorChoices.DEFAULT
|
||||
permissions = []
|
||||
_url = 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):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if color is not None:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
|
||||
@@ -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.jobs import DummySystemJob
|
||||
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.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
@@ -227,3 +227,46 @@ class PluginTest(TestCase):
|
||||
Test the registration of 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'])
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important;padding-top:var(--px-16)}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{background:hsl(var(--color-base))!important;border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);color:hsl(var(--color-neutral))!important;margin:0 var(--px-8);padding:var(--px-4) var(--px-6)}.graphiql-operation-title-bar .toolbar-button{line-height:0;margin-left:var(--px-8);color:hsla(var(--color-neutral),var(--alpha-secondary, .6));font-size:var(--font-size-h3);vertical-align:middle}.graphiql-explorer-graphql-arguments input{line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{padding-top:var(--px-16);border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}.graphiql-explorer-root>div{overflow:auto!important}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);margin:0 var(--px-8);padding:var(--px-4)var(--px-6);background:hsl(var(--color-base))!important;color:hsl(var(--color-neutral))!important}.toolbar-button{all:unset;cursor:pointer;margin-left:var(--px-6);color:hsl(var(--color-primary));line-height:0!important;font-size:var(--font-size-h3)!important}.graphiql-explorer-slug .toolbar-button,.graphiql-explorer-graphql-arguments .toolbar-button{font-size:inherit!important}.graphiql-explorer-graphql-arguments input{min-width:2rem;line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@graphiql/plugin-explorer": "3.2.6",
|
||||
"@graphiql/plugin-explorer": "4.0.6",
|
||||
"graphiql": "4.1.2",
|
||||
"graphql": "16.12.0",
|
||||
"js-cookie": "3.0.5",
|
||||
|
||||
@@ -294,10 +294,10 @@
|
||||
react-compiler-runtime "19.1.0-rc.1"
|
||||
zustand "^5"
|
||||
|
||||
"@graphiql/plugin-explorer@3.2.6":
|
||||
version "3.2.6"
|
||||
resolved "https://registry.npmjs.org/@graphiql/plugin-explorer/-/plugin-explorer-3.2.6.tgz"
|
||||
integrity sha512-MXzG/zVNzZfes4Em253bHyAbD/lwwAZkPKvxCAQkjz0i3dtcv4uF3D8iqJ7214iu3SCphbORYZZUC93fik1yew==
|
||||
"@graphiql/plugin-explorer@4.0.6":
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-4.0.6.tgz#bec1207dc27334914590ab31f46c2e944bbf4ebf"
|
||||
integrity sha512-TppIi92YPER3v70nlF01KTQrq9AiYqkZicSd1hpU7aqGmbqw/pLwBNLUEcfENBoJtw574Qxjswb01+GaYK0Tzw==
|
||||
dependencies:
|
||||
graphiql-explorer "^0.9.0"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-13 05:05+0000\n"
|
||||
"POT-Creation-Date: 2026-01-15 05:05+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -12489,8 +12489,8 @@ msgstr ""
|
||||
msgid "Delete Selected"
|
||||
msgstr ""
|
||||
|
||||
#: netbox/netbox/plugins/navigation.py:55
|
||||
#: netbox/netbox/plugins/navigation.py:88
|
||||
#: netbox/netbox/plugins/navigation.py:53
|
||||
#: netbox/netbox/plugins/navigation.py:89
|
||||
msgid "Permissions must be passed as a tuple or list."
|
||||
msgstr ""
|
||||
|
||||
@@ -12498,7 +12498,7 @@ msgstr ""
|
||||
msgid "Buttons must be passed as a tuple or list."
|
||||
msgstr ""
|
||||
|
||||
#: netbox/netbox/plugins/navigation.py:92
|
||||
#: netbox/netbox/plugins/navigation.py:95
|
||||
msgid "Button color must be a choice within ButtonColorChoices."
|
||||
msgstr ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user