diff --git a/.github/ISSUE_TEMPLATE/06-deprecation.yaml b/.github/ISSUE_TEMPLATE/06-deprecation.yaml index 8a8e29b27..99c64ba24 100644 --- a/.github/ISSUE_TEMPLATE/06-deprecation.yaml +++ b/.github/ISSUE_TEMPLATE/06-deprecation.yaml @@ -1,20 +1,26 @@ --- -name: 🗑️ Deprecation +name: ⚠️ 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"] body: - type: textarea attributes: - label: Proposed Changes + label: Deprecated Functionality 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: required: true - type: textarea attributes: label: Justification - description: Please provide justification for the proposed change(s). + description: Please provide justification for the deprecation. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/07-feature_removal.yaml b/.github/ISSUE_TEMPLATE/07-feature_removal.yaml new file mode 100644 index 000000000..837bc2704 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/07-feature_removal.yaml @@ -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 diff --git a/.gitignore b/.gitignore index eb1eccbef..ea2ce9512 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ yarn-error.log* /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py /netbox/local/* -/netbox/media +/netbox/media/* +!/netbox/media/.gitkeep /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* diff --git a/docs/features/change-logging.md b/docs/features/change-logging.md index 73e23709c..48cf8756d 100644 --- a/docs/features/change-logging.md +++ b/docs/features/change-logging.md @@ -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 diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 2b97f601c..66a95d924 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -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 diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py index c02c89948..50ce98cfb 100644 --- a/netbox/dcim/graphql/filter_mixins.py +++ b/netbox/dcim/graphql/filter_mixins.py @@ -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() diff --git a/netbox/media/.gitkeep b/netbox/media/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 34b66ada0..052200f47 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -232,7 +232,7 @@ VPN_MENU = Menu( label=_('L2VPNs'), items=( get_model_item('vpn', 'l2vpn', _('L2VPNs')), - get_model_item('vpn', 'l2vpntermination', _('Terminations')), + get_model_item('vpn', 'l2vpntermination', _('L2VPN Terminations')), ), ), MenuGroup( diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py index 2b18a4a0e..2062e95d5 100644 --- a/netbox/netbox/plugins/navigation.py +++ b/netbox/netbox/plugins/navigation.py @@ -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.")) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 550dca514..a8595d10d 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -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']) diff --git a/netbox/project-static/netbox-graphiql/package.json b/netbox/project-static/netbox-graphiql/package.json index 96291d5e4..6d39a68df 100644 --- a/netbox/project-static/netbox-graphiql/package.json +++ b/netbox/project-static/netbox-graphiql/package.json @@ -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", diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 7deb5c7ea..dd06e4cb4 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -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" diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 67adfc129..6f6be3d55 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-08 05:04+0000\n" +"POT-Creation-Date: 2026-01-13 05:05+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1822,7 +1822,6 @@ msgid "ASN Count" msgstr "" #: netbox/circuits/tables/virtual_circuits.py:64 -#: netbox/netbox/navigation/menu.py:235 #: netbox/templates/circuits/virtualcircuit.html:87 #: netbox/templates/vpn/l2vpn.html:60 netbox/templates/vpn/tunnel.html:72 #: netbox/vpn/tables/tunnels.py:59 @@ -12190,6 +12189,10 @@ msgstr "" msgid "L2VPNs" msgstr "" +#: netbox/netbox/navigation/menu.py:235 +msgid "L2VPN Terminations" +msgstr "" + #: netbox/netbox/navigation/menu.py:241 msgid "IKE Proposals" msgstr "" @@ -15938,7 +15941,7 @@ msgstr "" #: netbox/users/forms/model_forms.py:126 msgid "" "Tokens must be at least 40 characters in length. Be sure to record " -"your key prior to submitting this form, as it will no longer be " +"your token prior to submitting this form, as it will no longer be " "accessible once the token has been created." msgstr "" @@ -16077,7 +16080,7 @@ msgid "write enabled" msgstr "" #: 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 "" #: netbox/users/models/tokens.py:76 @@ -16126,12 +16129,16 @@ msgstr "" msgid "tokens" 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 msgid "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." msgstr "" -#: netbox/users/models/tokens.py:232 +#: netbox/users/models/tokens.py:235 #, python-brace-format msgid "" "Expiration time must be in the future. Current server time is {current_time} " diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index cc95b0ece..4c1a6a1eb 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -123,7 +123,7 @@ class UserTokenForm(forms.ModelForm): token = forms.CharField( label=_('Token'), help_text=_( - 'Tokens must be at least 40 characters in length. Be sure to record your key prior to ' + 'Tokens must be at least 40 characters in length. Be sure to record your token prior to ' 'submitting this form, as it will no longer be accessible once the token has been created.' ), widget=forms.TextInput( diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index bf51d6ef8..e58fc5830 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -69,7 +69,7 @@ class Token(models.Model): write_enabled = models.BooleanField( verbose_name=_('write enabled'), 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. plaintext = models.CharField( @@ -213,6 +213,9 @@ class Token(models.Model): def clean(self): 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.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: raise ValidationError(_( diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 367a82373..df3192260 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,9 +1,10 @@ from datetime import timedelta from django.core.exceptions import ValidationError -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils import timezone +from users.choices import TokenVersionChoices from users.models import User, Token from utilities.testing import create_test_user @@ -94,6 +95,15 @@ class TokenTest(TestCase): token.refresh_from_db() 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):