From a4c9cbc6ddf23e65558aa15780885189bb4ae34d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 2 Aug 2023 08:55:38 -0400 Subject: [PATCH 01/53] Remove hard-coded test runner --- netbox/netbox/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 58256d079..2744ba701 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -461,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -TEST_RUNNER = "django_rich.test.RichRunner" - # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( From a68831d3a1a438881e9ba254918d6b391a5303af Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 2 Aug 2023 17:25:54 +0530 Subject: [PATCH 02/53] fixes provider_network_id for related circuits #13343 --- netbox/circuits/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f1cfdd1d5..64dd82682 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView): related_models = ( ( Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), - 'providernetwork_id', + 'provider_network_id', ), ) From ab916a18191849d9e91c8344eef18d4a55e85555 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 1 Aug 2023 20:35:16 +0530 Subject: [PATCH 03/53] fixes dummy payload URL for webhook test --- netbox/extras/tests/test_webhooks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 19264dabb..ef7637765 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -31,8 +31,8 @@ class WebhookTest(APITestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) - DUMMY_URL = "http://localhost/" - DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" + DUMMY_URL = 'http://localhost:9000/' + DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING' webhooks = Webhook.objects.bulk_create(( Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), @@ -259,7 +259,7 @@ class WebhookTest(APITestCase): name='Conditional Webhook', type_create=True, type_update=True, - payload_url='http://localhost/', + payload_url='http://localhost:9000/', conditions={ 'and': [ { From 57860f26b778311e66115fe94cf6b95337f54593 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 3 Aug 2023 01:15:09 +0530 Subject: [PATCH 04/53] Adds assigned bool for IP address API (#13301) * adds assigned bool for ip address API #13151 * Add filterset test --------- Co-authored-by: Jeremy Stretch --- netbox/ipam/filtersets.py | 16 ++++++++++++++++ netbox/ipam/tests/test_filtersets.py | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d011472d9..9b57cb273 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='_assigned_to_interface', label=_('Is assigned to an interface'), ) + assigned = django_filters.BooleanFilter( + method='_assigned', + label=_('Is assigned'), + ) status = django_filters.MultipleChoiceFilter( choices=IPAddressStatusChoices, null_value=None @@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): assigned_object_id__isnull=False ) + def _assigned(self, queryset, name, value): + if value: + return queryset.exclude( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + else: + return queryset.filter( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + class FHRPGroupFilterSet(NetBoxModelFilterSet): protocol = django_filters.MultipleChoiceFilter( diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 3d9a66567..0ae7544ab 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_assigned(self): + params = {'assigned': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'assigned': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) From a807cca29e094a2aa1872411a63f2a0bac98acb8 Mon Sep 17 00:00:00 2001 From: Matej Vadnjal Date: Wed, 2 Aug 2023 22:08:14 +0200 Subject: [PATCH 05/53] Fixes #13033: add formatted speed column to Interfaces (#13275) * Fixes #13033: add formatted speed column to Interfaces * use TemplateColumn instead of own class --- netbox/dcim/tables/devices.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index db2655d27..42b34e999 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi } ) mgmt_only = columns.BooleanColumn() + speed_formatted = columns.TemplateColumn( + template_code='{% load helpers %}{{ value|humanize_speed }}', + accessor=Accessor('speed'), + verbose_name='Speed' + ) wireless_link = tables.Column( linkify=True ) @@ -568,7 +573,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', + 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', From 9cc295827b262693d43eb6f03dc0a3722fd3a88e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Aug 2023 14:53:58 -0400 Subject: [PATCH 06/53] Fixes #13369: Fix job termination status for failed reports --- netbox/extras/reports.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 8f3af2a09..6af81a9d9 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -214,20 +214,18 @@ class Report(object): self.active_test = method_name test_method = getattr(self, method_name) test_method() + job.data = self._results if self.failed: self.logger.warning("Report failed") - job.status = JobStatusChoices.STATUS_FAILED + job.terminate(status=JobStatusChoices.STATUS_FAILED) else: self.logger.info("Report completed successfully") - job.status = JobStatusChoices.STATUS_COMPLETED + job.terminate() except Exception as e: stacktrace = traceback.format_exc() self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") logger.error(f"Exception raised during report execution: {e}") job.terminate(status=JobStatusChoices.STATUS_ERRORED) - finally: - job.data = self._results - job.terminate() # Perform any post-run tasks self.post_run() From 93a862cded3b15d293c0680f58c45c2da6d2e397 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2023 08:55:43 -0400 Subject: [PATCH 07/53] Add stadium analogy and behavior anti-patterns --- CONTRIBUTING.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b71fb515..301fac079 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,25 @@

-Some general tips for engaging here on GitHub: +## :information_source: Welcome to the Stadium! + +In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well: + +> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers. + +The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users. + +If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them. + +NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others. + +### General Tips for Working on GitHub * Register for a free [GitHub account](https://github.com/signup) if you haven't already. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. +* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them. ## :bug: Reporting Bugs From 7f22c6bf12e5e4e18691fe65973f6a33dab8d066 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2023 10:12:15 -0400 Subject: [PATCH 08/53] Include notes re: demo data and netbox-docker --- docs/development/release-checklist.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index efb0f44b9..000948ee7 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. +### Rebuild Demo Data (After Release) + +After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions. + --- ## Patch Releases +### Notify netbox-docker Project of Any Relevant Changes + +Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including: + +* Significant changes to `upgrade.sh` +* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.) +* Any changes to the reference installation + ### Update Requirements Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this: From 43ce453938c9acdcd1bfdeebda906800e37ac86d Mon Sep 17 00:00:00 2001 From: Henrik Strand Date: Fri, 4 Aug 2023 17:32:52 +0200 Subject: [PATCH 09/53] Adding interface TYPE_400GE_CFP2/400gbase-x-cfp2 (#13338) * Added 400G CFP2 to InterfaceTypeChoices * Added new type to choises --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e850a8c51..21bd3ed7e 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -834,6 +834,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' + TYPE_400GE_CFP2 = '400gbase-x-cfp2' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_CDFP = '400gbase-x-cdfp' @@ -976,6 +977,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CFP, 'CFP (100GE)'), (TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'), + (TYPE_400GE_CFP2, 'CFP2 (400GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'), (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), From 01bb09db674d99316cc8347c26cb430268e9e8fb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 1 Aug 2023 18:55:42 +0530 Subject: [PATCH 10/53] adds delete for SyncedDataMixin when related AutoSyncRecord is available #12750 --- netbox/netbox/models/features.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8bacba534..1e55ec2a3 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -442,6 +442,19 @@ class SyncedDataMixin(models.Model): return ret + def delete(self, *args, **kwargs): + from core.models import AutoSyncRecord + + # Delete AutoSyncRecord + content_type = ContentType.objects.get_for_model(self) + AutoSyncRecord.objects.filter( + datafile=self.data_file, + object_type=content_type, + object_id=self.pk + ).delete() + + return super().delete(*args, **kwargs) + def resolve_data_file(self): """ Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if From 88562d7dcfb642532d24134abf26c7bea4dc7180 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2023 13:36:33 -0400 Subject: [PATCH 11/53] Changelog for #12750, #12889, #13033, #13151, #13343, #13369 --- docs/release-notes/version-3.5.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 66c2cabce..4347d9837 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -2,6 +2,18 @@ ## v3.5.8 (FUTURE) +### Enhancements + +* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type +* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table +* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses + +### Bug Fixes + +* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted +* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view +* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports + --- ## v3.5.7 (2023-07-28) From 0dd319d0c817ad71edf6542c4ffdac366e862822 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2023 14:05:41 -0400 Subject: [PATCH 12/53] Closes #11675: Add support for specifying import/export route targets during VRF bulk import --- netbox/ipam/forms/bulk_import.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 683d40f49..3bce26249 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,7 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Site @@ -10,7 +9,9 @@ from ipam.constants import * from ipam.models import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import ( + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField +) from virtualization.models import VirtualMachine, VMInterface __all__ = ( @@ -41,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) + import_targets = CSVModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + to_field_name='name', + help_text=_('Import route targets') + ) + export_targets = CSVModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + to_field_name='name', + help_text=_('Export route targets') + ) class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') + fields = ( + 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments', + 'tags', + ) class RouteTargetImportForm(NetBoxModelImportForm): From 2236b86c35c1f6bfe34aa426cf24eaf0be404237 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Aug 2023 14:37:40 -0400 Subject: [PATCH 13/53] Closes #11922: Populate assigned VDCs when adding a child interface --- netbox/dcim/forms/model_forms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 219216045..3c02e6e4e 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): queryset=VirtualDeviceContext.objects.all(), required=False, label='Virtual Device Contexts', + initial_params={ + 'interfaces': '$parent', + }, query_params={ 'device_id': '$device', } From f9648d854416590d98eb7b5d7cf6be18bd75b8cf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 7 Aug 2023 10:48:41 -0400 Subject: [PATCH 14/53] Closes #13400: Add 'name' property to BaseTable class --- netbox/netbox/tables/tables.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 20eab822d..975311e4a 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -54,7 +54,7 @@ class BaseTable(tables.Table): # 3. Meta.fields selected_columns = None if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + selected_columns = user.config.get(f"tables.{self.name}.columns") if not selected_columns: selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields) @@ -113,6 +113,10 @@ class BaseTable(tables.Table): columns.append((name, column.verbose_name)) return columns + @property + def name(self): + return self.__class__.__name__ + @property def available_columns(self): return self._get_columns(visible=False) @@ -138,17 +142,16 @@ class BaseTable(tables.Table): """ # Save ordering preference if request.user.is_authenticated: - table_name = self.__class__.__name__ if self.prefixed_order_by_field in request.GET: if request.GET[self.prefixed_order_by_field]: # If an ordering has been specified as a query parameter, save it as the # user's preferred ordering for this table. ordering = request.GET.getlist(self.prefixed_order_by_field) - request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) else: # If the ordering has been set to none (empty), clear any existing preference. - request.user.config.clear(f'tables.{table_name}.ordering', commit=True) - elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + request.user.config.clear(f'tables.{self.name}.ordering', commit=True) + elif ordering := request.user.config.get(f'tables.{self.name}.ordering'): # If no ordering has been specified, set the preferred ordering (if any). self.order_by = ordering From f5a1f83f9fa9d98c945d21eb0f7ccb8cd37fbf59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 7 Aug 2023 15:29:20 -0400 Subject: [PATCH 15/53] Closes #13368: Report installed plugins during server error (#13387) * Introduce get_installed_plugins() utility * Extend 500 error template to list installed plugins * Move get_plugin_config() to extras.plugins.utils --- netbox/extras/plugins/__init__.py | 21 ---------------- netbox/extras/plugins/utils.py | 37 +++++++++++++++++++++++++++++ netbox/extras/tests/test_plugins.py | 3 ++- netbox/netbox/api/views.py | 11 ++------- netbox/netbox/views/errors.py | 3 +++ netbox/templates/500.html | 5 +++- 6 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 netbox/extras/plugins/utils.py diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 83c7a7bb0..8736a3197 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -2,7 +2,6 @@ import collections from importlib import import_module from django.apps import AppConfig -from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from packaging import version @@ -146,23 +145,3 @@ class PluginConfig(AppConfig): for setting, value in cls.default_settings.items(): if setting not in user_config: user_config[setting] = value - - -# -# Utilities -# - -def get_plugin_config(plugin_name, parameter, default=None): - """ - Return the value of the specified plugin configuration parameter. - - Args: - plugin_name: The name of the plugin - parameter: The name of the configuration parameter - default: The value to return if the parameter is not defined (default: None) - """ - try: - plugin_config = settings.PLUGINS_CONFIG[plugin_name] - return plugin_config.get(parameter, default) - except KeyError: - raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py new file mode 100644 index 000000000..c260f156d --- /dev/null +++ b/netbox/extras/plugins/utils.py @@ -0,0 +1,37 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +__all__ = ( + 'get_installed_plugins', + 'get_plugin_config', +) + + +def get_installed_plugins(): + """ + Return a dictionary mapping the names of installed plugins to their versions. + """ + plugins = {} + for plugin_name in settings.PLUGINS: + plugin_name = plugin_name.rsplit('.', 1)[-1] + plugin_config = apps.get_app_config(plugin_name) + plugins[plugin_name] = getattr(plugin_config, 'version', None) + + return dict(sorted(plugins.items())) + + +def get_plugin_config(plugin_name, parameter, default=None): + """ + Return the value of the specified plugin configuration parameter. + + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) + """ + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index cb7629ad2..42dde43fd 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu, get_plugin_config +from extras.plugins import PluginMenu from extras.tests.dummy_plugin import config as dummy_config +from extras.plugins.utils import get_plugin_config from netbox.graphql.schema import Query from netbox.registry import registry diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 5c55697ff..97f690762 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -11,6 +11,7 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView from rq.worker import Worker +from extras.plugins.utils import get_installed_plugins from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -61,19 +62,11 @@ class StatusView(APIView): installed_apps[app_config.name] = version installed_apps = {k: v for k, v in sorted(installed_apps.items())} - # Gather installed plugins - plugins = {} - for plugin_name in settings.PLUGINS: - plugin_name = plugin_name.rsplit('.', 1)[-1] - plugin_config = apps.get_app_config(plugin_name) - plugins[plugin_name] = getattr(plugin_config, 'version', None) - plugins = {k: v for k, v in sorted(plugins.items())} - return Response({ 'django-version': DJANGO_VERSION, 'installed-apps': installed_apps, 'netbox-version': settings.VERSION, - 'plugins': plugins, + 'plugins': get_installed_plugins(), 'python-version': platform.python_version(), 'rq-workers-running': Worker.count(get_connection('default')), }) diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index c74c67cef..a81d45cb5 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View from sentry_sdk import capture_message +from extras.plugins.utils import get_installed_plugins + __all__ = ( 'handler_404', 'handler_500', @@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): 'exception': str(type_), 'netbox_version': settings.VERSION, 'python_version': platform.python_version(), + 'plugins': get_installed_plugins(), })) diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 6cface941..0257e7c43 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -30,7 +30,10 @@ {{ error }} Python version: {{ python_version }} -NetBox version: {{ netbox_version }} +NetBox version: {{ netbox_version }} +Plugins: {% for plugin, version in plugins.items %} + {{ plugin }}: {{ version }}{% empty %}None installed{% endfor %} +

If further assistance is required, please post to the NetBox discussion forum on GitHub.

From cd5012bd59d369473706cf94bb49a7ecf2f092e9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 10:12:13 -0400 Subject: [PATCH 16/53] Closes #13424: Move CloningMixin into NetBoxFeatureSet --- netbox/netbox/models/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 931d565ba..596357ea4 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -22,6 +22,7 @@ __all__ = ( class NetBoxFeatureSet( BookmarksMixin, ChangeLoggingMixin, + CloningMixin, CustomFieldsMixin, CustomLinksMixin, CustomValidationMixin, @@ -53,7 +54,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin abstract = True -class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): +class NetBoxModel(NetBoxFeatureSet, models.Model): """ Base model for most object types. Suitable for use by plugins. """ @@ -90,6 +91,10 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): }) +# +# NetBox internal base models +# + class PrimaryModel(NetBoxModel): """ Primary models represent real objects within the infrastructure being modeled. @@ -108,7 +113,7 @@ class PrimaryModel(NetBoxModel): abstract = True -class NestedGroupModel(CloningMixin, NetBoxFeatureSet, MPTTModel): +class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. From 646d52d49877a53552e07b75bdd7bd5848c1c9f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 10:12:40 -0400 Subject: [PATCH 17/53] Misc docs cleanup for v3.6 --- docs/plugins/development/models.md | 4 ++++ mkdocs.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index c51d025f4..8394813f8 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -26,7 +26,9 @@ Every model includes by default a numeric primary key. This value is generated a Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: +* Bookmarks * Change logging +* Cloning * Custom fields * Custom links * Custom validation @@ -105,6 +107,8 @@ For more information about database migrations, see the [Django documentation](h !!! warning Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. +::: netbox.models.features.BookmarksMixin + ::: netbox.models.features.ChangeLoggingMixin ::: netbox.models.features.CloningMixin diff --git a/mkdocs.yml b/mkdocs.yml index cde4a0acd..2203039f3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -211,6 +211,7 @@ nav: - ConfigContext: 'models/extras/configcontext.md' - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' + - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md' - CustomLink: 'models/extras/customlink.md' - ExportTemplate: 'models/extras/exporttemplate.md' - ImageAttachment: 'models/extras/imageattachment.md' From 4e8a3e0a6f787d7ebb7bd718fcc5b3d675af1a6f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 10:27:10 -0400 Subject: [PATCH 18/53] Closes #13426: Register all model features in the registry --- netbox/netbox/models/features.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 08693850f..f0ab29123 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -525,11 +525,20 @@ class SyncedDataMixin(models.Model): raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.") +# +# Feature registration +# + FEATURES_MAP = { 'bookmarks': BookmarksMixin, + 'change_logging': ChangeLoggingMixin, + 'cloning': CloningMixin, + 'contacts': ContactsMixin, 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, + 'custom_validation': CustomValidationMixin, 'export_templates': ExportTemplatesMixin, + 'image_attachments': ImageAttachmentsMixin, 'jobs': JobsMixin, 'journaling': JournalingMixin, 'synced_data': SyncedDataMixin, @@ -544,12 +553,13 @@ registry['model_features'].update({ @receiver(class_prepared) def _register_features(sender, **kwargs): + # Record each applicable feature for the model in the registry features = { feature for feature, cls in FEATURES_MAP.items() if issubclass(sender, cls) } register_features(sender, features) - # Feature view registration + # Register applicable feature views for the model if issubclass(sender, JournalingMixin): register_model_view( sender, From 5dce5563ab8cf393cbf0c0117f1d936b952df990 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 10:32:08 -0400 Subject: [PATCH 19/53] #11541: Fix object_types queryset on TagSerializer --- netbox/extras/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 7f1b941b6..4da5fa629 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -239,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') object_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), many=True, required=False ) From 16bcb1dbb0ce5bc611ca021a3f2417cb9faa10ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 10:41:40 -0400 Subject: [PATCH 20/53] #13426: Employ proper feature keys for image attachment & contact filter forms --- netbox/extras/forms/filtersets.py | 2 +- netbox/tenancy/forms/filtersets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 88cbf9e4d..1ea361a7c 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -180,7 +180,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): ) content_type_id = ContentTypeChoiceField( label=_('Content type'), - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), required=False ) name = forms.CharField( diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 543c4b1e5..692b8963f 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -88,7 +88,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + limit_choices_to=FeatureQuery('contacts'), required=False, label=_('Object type') ) From 545769ad884e473651673168d74b80ba2412a62b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 9 Aug 2023 23:46:03 +0530 Subject: [PATCH 21/53] Adds generic object children template (#13388) * adds generic tab view template #12110 * Rename view_tab.html and move to generic/ * Fix console ports template * Move bulk operations view resolution to template * Avoid setting default template_name on ObjectChildrenView * Move base_template and table_config context vars to base context * removed bulk_delete_control from templates * refactored bulk_controls view * fixed table_config * renamed object_tab.html to objectchildren_list.html * removed unused import * Refactor template blocks for bulk operation buttons * Rename object children generic template * Move disconnect bulk action into a separate template for device components * Fix cluster devices & VM interfaces views * minor button label change --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/views.py | 13 +++ netbox/ipam/views.py | 10 +-- netbox/netbox/views/generic/object_views.py | 3 + .../dcim/device/components_base.html | 15 ++++ .../templates/dcim/device/consoleports.html | 74 +++++------------ .../dcim/device/consoleserverports.html | 74 +++++------------ netbox/templates/dcim/device/devicebays.html | 57 +++---------- netbox/templates/dcim/device/frontports.html | 74 +++++------------ netbox/templates/dcim/device/interfaces.html | 83 +++++-------------- netbox/templates/dcim/device/inventory.html | 57 +++---------- netbox/templates/dcim/device/modulebays.html | 53 +++--------- .../templates/dcim/device/poweroutlets.html | 74 +++++------------ netbox/templates/dcim/device/powerports.html | 74 +++++------------ netbox/templates/dcim/device/rearports.html | 74 +++++------------ .../dcim/rack/non_racked_devices.html | 43 +--------- netbox/templates/dcim/rack/reservations.html | 49 ++--------- netbox/templates/generic/object_children.html | 57 +++++++++++++ netbox/templates/ipam/aggregate/prefixes.html | 39 +-------- netbox/templates/ipam/asnrange/asns.html | 36 -------- .../ipam/ipaddress/ip_addresses.html | 19 ----- .../templates/ipam/iprange/ip_addresses.html | 41 +-------- .../templates/ipam/prefix/ip_addresses.html | 39 +-------- netbox/templates/ipam/prefix/ip_ranges.html | 39 +-------- netbox/templates/ipam/prefix/prefixes.html | 40 +-------- netbox/templates/ipam/vlan/interfaces.html | 20 ----- netbox/templates/ipam/vlan/vminterfaces.html | 20 ----- netbox/templates/tenancy/object_contacts.html | 19 +---- .../virtualization/cluster/devices.html | 37 +++------ .../cluster/virtual_machines.html | 35 -------- .../virtualmachine/interfaces.html | 54 +++--------- netbox/tenancy/views.py | 5 -- netbox/virtualization/views.py | 19 ++++- 32 files changed, 337 insertions(+), 1009 deletions(-) create mode 100644 netbox/templates/dcim/device/components_base.html create mode 100644 netbox/templates/generic/object_children.html delete mode 100644 netbox/templates/ipam/asnrange/asns.html delete mode 100644 netbox/templates/ipam/ipaddress/ip_addresses.html delete mode 100644 netbox/templates/ipam/vlan/interfaces.html delete mode 100644 netbox/templates/ipam/vlan/vminterfaces.html delete mode 100644 netbox/templates/virtualization/cluster/virtual_machines.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5b93e5f0b..fca222f47 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,4 +1,5 @@ import traceback +from collections import defaultdict from django.contrib import messages from django.contrib.contenttypes.models import ContentType @@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = { class DeviceComponentsView(generic.ObjectChildrenView): + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + 'bulk_rename': {'change'}, + 'bulk_disconnect': {'change'}, + }) queryset = Device.objects.all() def get_children(self, request, parent): @@ -1997,6 +2007,7 @@ class DeviceModuleBaysView(DeviceComponentsView): table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet template_name = 'dcim/device/modulebays.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') tab = ViewTab( label=_('Module Bays'), badge=lambda obj: obj.modulebays.count(), @@ -2012,6 +2023,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet template_name = 'dcim/device/devicebays.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') tab = ViewTab( label=_('Device Bays'), badge=lambda obj: obj.devicebays.count(), @@ -2023,6 +2035,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): @register_model_view(Device, 'inventory') class DeviceInventoryView(DeviceComponentsView): + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') child_model = InventoryItem table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 32badd2d5..d8e4d8b47 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -216,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): child_model = ASN table = tables.ASNTable filterset = filtersets.ASNFilterSet - template_name = 'ipam/asnrange/asns.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), @@ -816,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView): table = None if form.is_valid(): - addresses = self.queryset.prefetch_related('vrf', 'tenant') # Limit to 100 results addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100] @@ -866,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet - template_name = 'ipam/ipaddress/ip_addresses.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Related IPs'), badge=lambda x: x.get_related_ips().count(), @@ -963,7 +962,6 @@ class FHRPGroupView(generic.ObjectView): queryset = FHRPGroup.objects.all() def get_extra_context(self, request, instance): - # Get assigned interfaces members_table = tables.FHRPGroupAssignmentTable( data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), @@ -1077,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView): child_model = Interface table = tables.VLANDevicesTable filterset = InterfaceFilterSet - template_name = 'ipam/vlan/interfaces.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Device Interfaces'), badge=lambda x: x.get_interfaces().count(), @@ -1095,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView): child_model = VMInterface table = tables.VLANVirtualMachinesTable filterset = VMInterfaceFilterSet - template_name = 'ipam/vlan/vminterfaces.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('VM Interfaces'), badge=lambda x: x.get_vminterfaces().count(), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 1ba789cf1..99d8ff540 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): return render(request, self.get_template_name(), { 'object': instance, 'child_model': self.child_model, + 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', 'table': table, + 'table_config': f'{table.name}_config', 'actions': actions, 'tab': self.tab, + 'return_url': request.get_full_path(), **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/dcim/device/components_base.html b/netbox/templates/dcim/device/components_base.html new file mode 100644 index 000000000..1e3d8a39d --- /dev/null +++ b/netbox/templates/dcim/device/components_base.html @@ -0,0 +1,15 @@ +{% extends 'generic/object_children.html' %} +{% load helpers %} + +{% block bulk_edit_controls %} + {{ block.super }} + {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} + {% if 'bulk_rename' in actions and bulk_rename_view %} + + {% endif %} + {% endwith %} +{% endblock bulk_edit_controls %} diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index ccd12f61c..6e1c1b699 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_consoleport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_consoleport %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index 43396651d..637f06118 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_consoleserverport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_consoleserverport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 9453b9a59..0a7bbba7f 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -1,50 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_devicebay %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_devicebay %} - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index dd0767d95..453064611 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_frontport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_frontport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index c0e9a38b6..778101265 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -1,66 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} -
- {% if 'bulk_delete' in actions %} - +{% block bulk_delete_controls %} + {{ block.super }} + {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} + {% if 'bulk_disconnect' in actions and bulk_disconnect_view %} + {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
+ {% endwith %} +{% endblock bulk_delete_controls %} + +{% block bulk_extra_controls %} + {{ block.super }} {% if perms.dcim.add_interface %} - + {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 9e11031ec..d4c9a9b68 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -1,50 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_inventoryitem %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_inventoryitem %} - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index 7f0aacf1f..fc616f828 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -1,46 +1,13 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
- {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_modulebay %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_modulebay %} - {% endif %} -
-
- {% table_config_form table %} -{% endblock %} + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 66b21b7af..f31067453 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_poweroutlet %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_poweroutlet %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index d9e1e121a..ad1dbacd8 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% load static %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_powerport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_powerport %} + + {% endif %} +{% endblock bulk_extra_controls %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index ce194cc78..dfa406386 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -1,57 +1,27 @@ -{% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load static %} +{% extends 'dcim/device/components_base.html' %} {% load helpers %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- - -
{% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} - {% if 'bulk_edit' in actions %} - - {% endif %} -
-
- {% if perms.dcim.add_rearport %} - - {% endif %} -
-
-{% endblock %} + {% endwith %} +{% endblock bulk_delete_controls %} -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% block bulk_extra_controls %} + {{ block.super }} + {% if perms.dcim.add_rearport %} + + {% endif %} +{% endblock bulk_extra_controls %} \ No newline at end of file diff --git a/netbox/templates/dcim/rack/non_racked_devices.html b/netbox/templates/dcim/rack/non_racked_devices.html index 700c66369..e52b8647f 100644 --- a/netbox/templates/dcim/rack/non_racked_devices.html +++ b/netbox/templates/dcim/rack/non_racked_devices.html @@ -1,5 +1,4 @@ -{% extends 'dcim/rack/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% if perms.dcim.add_device %} @@ -10,42 +9,4 @@ {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/dcim/rack/reservations.html b/netbox/templates/dcim/rack/reservations.html index fb357e592..a01cf3b7e 100644 --- a/netbox/templates/dcim/rack/reservations.html +++ b/netbox/templates/dcim/rack/reservations.html @@ -1,43 +1,12 @@ -{% extends 'dcim/rack/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="RackReservationTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
- {% if perms.dcim.add_rackreservation %} +{% block extra_controls %} + {% if perms.dcim.add_rackreservation %} - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock extra_controls %} diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html new file mode 100644 index 000000000..eb5c65827 --- /dev/null +++ b/netbox/templates/generic/object_children.html @@ -0,0 +1,57 @@ +{% extends base_template %} +{% load helpers %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal=table_config %} +
+ {% csrf_token %} +
+
+ {% include 'htmx/table.html' %} +
+
+
+ {% block bulk_controls %} +
+
+ {# Bulk edit buttons #} + {% block bulk_edit_controls %} + {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} + {% if 'bulk_edit' in actions and bulk_edit_view %} + + {% endif %} + {% endwith %} + {% endblock bulk_edit_controls %} +
+
+ {# Bulk delete buttons #} + {% block bulk_delete_controls %} + {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %} + {% if 'bulk_delete' in actions and bulk_delete_view %} + + {% endif %} + {% endwith %} + {% endblock bulk_delete_controls %} +
+
+
+ {# Other bulk action buttons #} + {% block bulk_extra_controls %}{% endblock %} +
+ {% endblock bulk_controls %} +
+
+{% endblock content %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index a1d3bd276..7820e121e 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -1,5 +1,4 @@ -{% extends 'ipam/aggregate/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} @@ -9,38 +8,4 @@ {% endif %} {{ block.super }} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/asnrange/asns.html b/netbox/templates/ipam/asnrange/asns.html deleted file mode 100644 index 69d4e8abb..000000000 --- a/netbox/templates/ipam/asnrange/asns.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'ipam/asnrange/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="ASNTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/ipam/ipaddress/ip_addresses.html b/netbox/templates/ipam/ipaddress/ip_addresses.html deleted file mode 100644 index b82ec2375..000000000 --- a/netbox/templates/ipam/ipaddress/ip_addresses.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'ipam/ipaddress/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index 9f77f6c78..869fd0fa1 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -1,44 +1,9 @@ -{% extends 'ipam/iprange/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %} + {% if perms.ipam.add_ipaddress and object.first_available_ip %} Add IP Address {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index fe68039f8..f9d5febbe 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% if perms.ipam.add_ipaddress and first_available_ip %} @@ -7,38 +6,4 @@ Add IP Address {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 4452fd5a7..8371de81d 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% if perms.ipam.add_iprange and first_available_ip %} @@ -7,38 +6,4 @@ Add IP Range {% endif %} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 5fc931f74..41407e870 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -1,5 +1,4 @@ -{% extends 'ipam/prefix/base.html' %} -{% load helpers %} +{% extends 'generic/object_children.html' %} {% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} @@ -8,39 +7,4 @@ Add Prefix {% endif %} - {{ block.super }} -{% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} +{% endblock extra_controls %} diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html deleted file mode 100644 index f7bcc8563..000000000 --- a/netbox/templates/ipam/vlan/interfaces.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'ipam/vlan/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html deleted file mode 100644 index a485b33eb..000000000 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'ipam/vlan/base.html' %} -{% load helpers %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html index e13fedc43..95727604c 100644 --- a/netbox/templates/tenancy/object_contacts.html +++ b/netbox/templates/tenancy/object_contacts.html @@ -1,4 +1,4 @@ -{% extends base_template %} +{% extends 'generic/object_children.html' %} {% load helpers %} {% block extra_controls %} @@ -10,20 +10,3 @@ {% endwith %} {% endif %} {% endblock %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index 083798233..271240ed1 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -1,30 +1,13 @@ -{% extends 'virtualization/cluster/base.html' %} +{% extends 'generic/object_children.html' %} {% load helpers %} -{% load render_table from django_tables2 %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-
- {% if perms.virtualization.change_cluster %} - - {% endif %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + + {% endif %} +{% endblock bulk_delete_controls %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html deleted file mode 100644 index 79c489d6b..000000000 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends 'virtualization/cluster/base.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %} - -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-
- {% if 'bulk_edit' in actions %} - - {% endif %} - {% if 'bulk_delete' in actions %} - - {% endif %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index 71456d104..ee4e76926 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -1,47 +1,13 @@ -{% extends 'virtualization/virtualmachine/base.html' %} -{% load render_table from django_tables2 %} +{% extends 'generic/object_children.html' %} {% load helpers %} -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
- {% if perms.virtualization.change_vminterface %} -
- - -
- {% endif %} - {% if perms.virtualization.delete_vminterface %} - - {% endif %} - {% if perms.virtualization.add_vminterface %} - - {% endif %} -
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} + {% endif %} +{% endblock bulk_edit_controls %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 23020e794..3025e7e04 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -41,11 +41,6 @@ class ObjectContactsView(generic.ObjectChildrenView): return table - def get_extra_context(self, request, instance): - return { - 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', - } - # # Tenant groups # diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 75e83f9e1..92a91f47e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib import messages from django.db import transaction from django.db.models import Prefetch, Sum @@ -175,7 +177,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView): child_model = VirtualMachine table = tables.VirtualMachineTable filterset = filtersets.VirtualMachineFilterSet - template_name = 'virtualization/cluster/virtual_machines.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Virtual Machines'), badge=lambda obj: obj.virtual_machines.count(), @@ -194,6 +196,13 @@ class ClusterDevicesView(generic.ObjectChildrenView): table = DeviceTable filterset = DeviceFilterSet template_name = 'virtualization/cluster/devices.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_remove_devices': {'change'}, + }) tab = ViewTab( label=_('Devices'), badge=lambda obj: obj.devices.count(), @@ -353,6 +362,14 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): permission='virtualization.view_vminterface', weight=500 ) + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + 'bulk_rename': {'change'}, + }) def get_children(self, request, parent): return parent.interfaces.restrict(request.user, 'view').prefetch_related( From 9b1406a1a7ca566d084fae6951ac9126bd57dc59 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 9 Aug 2023 11:56:30 +0200 Subject: [PATCH 22/53] Don't hide HIDDEN_IFUNSET custom fields from bulk import fields --- netbox/netbox/forms/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f..b406ab04e 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -78,7 +78,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).filter( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ui_visibility__in=[ + CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET, + ] ) def _get_form_field(self, customfield): From dcdb4d27eced5671bd081ef905efff710ee68820 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 9 Aug 2023 12:28:35 +0700 Subject: [PATCH 23/53] 12665 add semicolon to link sanitation safe string --- netbox/extras/models/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index fcf5c26a2..c76a5a76f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote(link, safe='/:?&=%+[]@#,') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;') # Verify link scheme is allowed result = urllib.parse.urlparse(link) From 8b01c30c51a623f23d12cffea51d8f5805cdeba0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 10 Aug 2023 00:27:59 +0530 Subject: [PATCH 24/53] Exposes all models in device context data (#13389) * exposes all models in device context data #12814 * added app namespaces to the context data * revert object to device in context data * moved context to render method of ConfigTemplate * removed print * Include only registered models; permit passed context data to overwrite apps --------- Co-authored-by: Jeremy Stretch --- netbox/extras/models/configs.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 6bd6019b9..47e8dcd82 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.conf import settings from django.core.validators import ValidationError from django.db import models @@ -8,6 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment from extras.querysets import ConfigContextQuerySet from netbox.config import get_config +from netbox.registry import registry from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from utilities.jinja2 import ConfigTemplateLoader @@ -255,7 +257,19 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog """ Render the contents of the template. """ - context = context or {} + _context = dict() + + # Populate the default template context with NetBox model classes, namespaced by app + # TODO: Devise a canonical mechanism for identifying the models to include (see #13427) + for app, model_names in registry['model_features']['custom_fields'].items(): + _context.setdefault(app, {}) + for model_name in model_names: + model = apps.get_registered_model(app, model_name) + _context[app][model.__name__] = model + + # Add the provided context data, if any + if context is not None: + _context.update(context) # Initialize the Jinja2 environment and instantiate the Template environment = self._get_environment() @@ -263,7 +277,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog template = environment.get_template(self.data_file.path) else: template = environment.from_string(self.template_code) - output = template.render(**context) + output = template.render(**_context) # Replace CRLF-style line terminators return output.replace('\r\n', '\n') From 72e1e8fab1ad0379fb0b126f66f6aaad805ce843 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 15:02:49 -0400 Subject: [PATCH 25/53] Changelog for #11675, #11922, #12665, #13368, #13414 --- docs/release-notes/version-3.5.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 4347d9837..cf47a3b23 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,15 +4,20 @@ ### Enhancements +* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import +* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI * [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type * [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table * [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses +* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page ### Bug Fixes +* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links * [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted * [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view * [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports +* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms --- From ff598458219e92c41c0a46ede7a855a0411a0dc9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Aug 2023 15:38:03 -0400 Subject: [PATCH 26/53] Changelog for #12814, #13037, #13376, #13410 --- docs/release-notes/version-3.6.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index b5252b7e5..032ad4f10 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -7,6 +7,8 @@ * [#13351](https://github.com/netbox-community/netbox/issues/13351) - Fix missing text due to incorrectly applied translation tags * [#13361](https://github.com/netbox-community/netbox/issues/13361) - Extra choices field on custom field choice set form should not be required * [#13363](https://github.com/netbox-community/netbox/issues/13363) - Fix API endpoint for custom field choice selector in forms +* [#13376](https://github.com/netbox-community/netbox/issues/13376) - Restrict add/remove tag fields by model on bulk edit forms +* [#13410](https://github.com/netbox-community/netbox/issues/13410) - Fix rendering of custom choice fields with large number of choices --- @@ -18,6 +20,7 @@ * The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only. * The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model. * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model. +* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models. ### New Features @@ -70,7 +73,9 @@ Tags may now be restricted to use with designated object types. Tags that have n * [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks * [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one * [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds +* [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context * [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments +* [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API * [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate * [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types @@ -122,6 +127,10 @@ Tags may now be restricted to use with designated object types. Tags that have n * extras.CustomField * Removed the `choices` array field * Added the `choice_set` foreign key field (to ChoiceSet) +* extras.Report + * Reports are now returned within a `results` list +* extras.Script + * Scripts are now returned within a `results` list * extras.Tag * Added the `object_types` field for optional restriction to specific object types * extras.Webhook From 23b3f72dee54c135784c71917de873da1f7137be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 09:38:12 -0400 Subject: [PATCH 27/53] Apply missed string translations --- netbox/templates/generic/object_children.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index eb5c65827..a0f33e1b1 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -21,7 +21,7 @@ {% endif %} {% endwith %} @@ -35,7 +35,7 @@ {% endif %} {% endwith %} From 4d2ef0a8b5d5694aff515d2a06a1549037891d4a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 10:04:31 -0400 Subject: [PATCH 28/53] Fixes #13433: User field on API token form should be required --- netbox/users/forms/model_forms.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 74a44051f..684691e02 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -143,10 +143,8 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm): class TokenForm(UserTokenForm): user = forms.ModelChoiceField( - queryset=get_user_model().objects.order_by( - 'username' - ), - required=False + queryset=get_user_model().objects.order_by('username'), + label=_('User') ) class Meta: From 89d8f7aa70336f9cd7a50590eab6752da84be0c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 10:32:56 -0400 Subject: [PATCH 29/53] Add missing load tag for i18n --- netbox/templates/generic/object_children.html | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index a0f33e1b1..0fa59d1ec 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -1,5 +1,6 @@ {% extends base_template %} {% load helpers %} +{% load i18n %} {% block content %} {% include 'inc/table_controls_htmx.html' with table_modal=table_config %} From 856cc0f885c359c003299529fe20f0398b8ffe63 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 13:55:03 -0400 Subject: [PATCH 30/53] Fixes #13437: Display bookmark button only for relevant objects --- netbox/templates/generic/object.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index efdd31db7..90a37cca5 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -60,7 +60,7 @@ Context: {# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if perms.extras.add_bookmark %} + {% if perms.extras.add_bookmark and object.bookmarks %} {% bookmark_button object %} {% endif %} {% if request.user|can_add:object %} From a332adf96254cae83495c3c76518d9796b57fb7d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 14:11:16 -0400 Subject: [PATCH 31/53] Fixes #13434: Randomly generate initial keys prior to the creation of new tokens --- netbox/users/forms/model_forms.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 684691e02..5fe84ad5f 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -111,8 +111,10 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe class UserTokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( label=_('Key'), - required=False, - help_text=_("If no key is provided, one will be generated automatically.") + help_text=_( + 'Keys must be at least 40 characters in length. Be sure to record your key prior to ' + 'submitting this form, as it may no longer be accessible once the token has been created.' + ) ) allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(validators=[prefix_validator]), @@ -140,6 +142,10 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm): if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: del self.fields['key'] + # Generate an initial random key if none has been specified + if not self.instance.pk and not self.initial.get('key'): + self.initial['key'] = Token.generate_key() + class TokenForm(UserTokenForm): user = forms.ModelChoiceField( From 1ff1b4dc89da4bbe1aaac3394cb515e63b16bf8d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 14:12:42 -0400 Subject: [PATCH 32/53] Changelog for #13433, #13434, #13437 --- docs/release-notes/version-3.6.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 032ad4f10..a341a5464 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -9,6 +9,9 @@ * [#13363](https://github.com/netbox-community/netbox/issues/13363) - Fix API endpoint for custom field choice selector in forms * [#13376](https://github.com/netbox-community/netbox/issues/13376) - Restrict add/remove tag fields by model on bulk edit forms * [#13410](https://github.com/netbox-community/netbox/issues/13410) - Fix rendering of custom choice fields with large number of choices +* [#13433](https://github.com/netbox-community/netbox/issues/13433) - User field on API token form should be required +* [#13434](https://github.com/netbox-community/netbox/issues/13434) - Randomly generate initial keys prior to the creation of new tokens +* [#13437](https://github.com/netbox-community/netbox/issues/13437) - Display bookmark button only for relevant objects --- From 315c4bb1ac57b2c59606e3071572b236e0c02e4f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 10 Aug 2023 14:32:48 -0400 Subject: [PATCH 33/53] #13434: Fix tests --- netbox/users/tests/test_views.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 2997052eb..c6408fc01 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -171,22 +171,23 @@ class TokenTestCase( create_test_user('User 2'), ) tokens = ( - Token(key='123456790123456789012345678901234567890A', user=users[0]), - Token(key='123456790123456789012345678901234567890B', user=users[0]), - Token(key='123456790123456789012345678901234567890C', user=users[1]), + Token(key='123456789012345678901234567890123456789A', user=users[0]), + Token(key='123456789012345678901234567890123456789B', user=users[0]), + Token(key='123456789012345678901234567890123456789C', user=users[1]), ) Token.objects.bulk_create(tokens) cls.form_data = { 'user': users[0].pk, + 'key': '1234567890123456789012345678901234567890', 'description': 'testdescription', } cls.csv_data = ( "key,user,description", - f"123456790123456789012345678901234567890D,{users[0].pk},testdescriptionD", - f"123456790123456789012345678901234567890E,{users[1].pk},testdescriptionE", - f"123456790123456789012345678901234567890F,{users[1].pk},testdescriptionF", + f"123456789012345678901234567890123456789D,{users[0].pk},testdescriptionD", + f"123456789012345678901234567890123456789E,{users[1].pk},testdescriptionE", + f"123456789012345678901234567890123456789F,{users[1].pk},testdescriptionF", ) cls.csv_update_data = ( From dc7411e4c5fe72c2dfa79e925fb014bcc7553189 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 11 Aug 2023 08:56:58 -0400 Subject: [PATCH 34/53] Fixes #13446: Don't disable bulk edit/delete buttons after deselecting "select all" checkbox --- docs/release-notes/version-3.5.md | 1 + netbox/project-static/dist/netbox.js | Bin 530613 -> 530368 bytes netbox/project-static/dist/netbox.js.map | Bin 450868 -> 450659 bytes .../project-static/src/buttons/selectAll.ts | 30 +----------------- 4 files changed, 2 insertions(+), 29 deletions(-) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index cf47a3b23..fe0832c3b 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -18,6 +18,7 @@ * [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view * [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports * [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms +* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox --- diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index b62436d757a2b299e5bf0f33dcc4fbd67ec21e8c..84bfecae34920e732ecf52670208390ce6712040 100644 GIT binary patch delta 9766 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 R=VTT5U$4QMX#G;={{R{n`nLc8 delta 9842 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 diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index ed3833f982a5d0f0d81c6bcf7e90a86aeb99d789..7f2400ed2610973ed909703e534ec9b73c19c8ae 100644 GIT binary patch delta 44 zcmV+{0Mq}p!W-kj8-RoXgaU*Egaot&HT?rsH$s;W>ID^-;r#_p1Vd~^cDHc;1;pZx C4H8xW delta 254 zcmYL@%?bfw6o&INYb&MP!YM_R<$I1XO&XJiG)qlHgzVU`n0uI|o0zhdTnRh(Fy%XD zVRL#;y?^g}J$=+KaUm|orMR40?d%-wg+k!SG& r%d8<_AO4F%b#|B{*bT`;&4S3Z%JLU7(OR8kvdj+Y_D{EKW<6hDCyPpW diff --git a/netbox/project-static/src/buttons/selectAll.ts b/netbox/project-static/src/buttons/selectAll.ts index 64b98d390..f40520e26 100644 --- a/netbox/project-static/src/buttons/selectAll.ts +++ b/netbox/project-static/src/buttons/selectAll.ts @@ -1,4 +1,4 @@ -import { getElement, getElements, findFirstAdjacent } from '../util'; +import { getElements, findFirstAdjacent } from '../util'; /** * If any PK checkbox is checked, uncheck the select all table checkbox and the select all @@ -63,29 +63,6 @@ function handleSelectAllToggle(event: Event): void { } } -/** - * Synchronize the select all confirmation checkbox state with the select all confirmation button - * disabled state. If the select all confirmation checkbox is checked, the buttons should be - * enabled. If not, the buttons should be disabled. - * - * @param event Change Event - */ -function handleSelectAll(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const selectAllBox = getElement('select-all-box'); - if (selectAllBox !== null) { - for (const button of selectAllBox.querySelectorAll( - 'button[type="submit"]', - )) { - if (target.checked) { - button.disabled = false; - } else { - button.disabled = true; - } - } - } -} - /** * Initialize table select all elements. */ @@ -98,9 +75,4 @@ export function initSelectAll(): void { for (const element of getElements('input[type="checkbox"][name="pk"]')) { element.addEventListener('change', handlePkCheck); } - const selectAll = getElement('select-all'); - - if (selectAll !== null) { - selectAll.addEventListener('change', handleSelectAll); - } } From 9fd07b594c13266736cd67f1005334b916062828 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 11 Aug 2023 20:49:03 +0700 Subject: [PATCH 35/53] 11578 mark swagger available- apis to accept lists in post (#13445) * 11578 change swagger for available-ips to accept lists * 11578 change swagger for available-xxx to accept lists --- netbox/ipam/api/views.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 99b4c023d..feffc3ff2 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -224,7 +224,10 @@ class AvailableASNsView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.ASNSerializer(many=True)}, + request=serializers.ASNSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -293,7 +296,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.PrefixSerializer(many=True)}, + request=serializers.PrefixSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -388,7 +394,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.IPAddressSerializer(many=True)}, + request=serializers.IPAddressSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -468,7 +477,10 @@ class AvailableVLANsView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.VLANSerializer(many=True)}, + request=serializers.VLANSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') From 40afe6cf36be56c2aa3856a34254aea8c8934e14 Mon Sep 17 00:00:00 2001 From: "Daniel W. Anner" Date: Fri, 11 Aug 2023 11:00:26 -0400 Subject: [PATCH 36/53] Feature - Schema Generation (#13353) * Schema generation is working * Added option to either dump to a file or the console * Moving schema file and utilizing settings definition for file paths * Cleaning up the imports and fixing a few pythonic issues * Tweak command flags * Clean up choices mapping * Misc cleanup * Rename & move template file * Move management command from extras to dcim * Update release checklist --------- Co-authored-by: Jeremy Stretch --- contrib/generated_schema.json | 561 ++++++++++++++++++ docs/development/release-checklist.md | 10 + .../dcim/management/commands/buildschema.py | 62 ++ .../extras/schema/devicetype_schema.jinja2 | 93 +++ 4 files changed, 726 insertions(+) create mode 100644 contrib/generated_schema.json create mode 100644 netbox/dcim/management/commands/buildschema.py create mode 100644 netbox/templates/extras/schema/devicetype_schema.jinja2 diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json new file mode 100644 index 000000000..8dbcb2847 --- /dev/null +++ b/contrib/generated_schema.json @@ -0,0 +1,561 @@ +{ + "type": "object", + "additionalProperties": false, + "definitions": { + "airflow": { + "type": "string", + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "passive", + "mixed" + ] + }, + "weight-unit": { + "type": "string", + "enum": [ + "kg", + "g", + "lb", + "oz" + ] + }, + "subdevice-role": { + "type": "string", + "enum": [ + "parent", + "child" + ] + }, + "console-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "console-server-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "de-9", + "db-25", + "rj-11", + "rj-12", + "rj-45", + "mini-din-8", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "other" + ] + } + } + }, + "power-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c6", + "iec-60320-c8", + "iec-60320-c14", + "iec-60320-c16", + "iec-60320-c20", + "iec-60320-c22", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15p", + "nema-5-15p", + "nema-5-20p", + "nema-5-30p", + "nema-5-50p", + "nema-6-15p", + "nema-6-20p", + "nema-6-30p", + "nema-6-50p", + "nema-10-30p", + "nema-10-50p", + "nema-14-20p", + "nema-14-30p", + "nema-14-50p", + "nema-14-60p", + "nema-15-15p", + "nema-15-20p", + "nema-15-30p", + "nema-15-50p", + "nema-15-60p", + "nema-l1-15p", + "nema-l5-15p", + "nema-l5-20p", + "nema-l5-30p", + "nema-l5-50p", + "nema-l6-15p", + "nema-l6-20p", + "nema-l6-30p", + "nema-l6-50p", + "nema-l10-30p", + "nema-l14-20p", + "nema-l14-30p", + "nema-l14-50p", + "nema-l14-60p", + "nema-l15-20p", + "nema-l15-30p", + "nema-l15-50p", + "nema-l15-60p", + "nema-l21-20p", + "nema-l21-30p", + "nema-l22-30p", + "cs6361c", + "cs6365c", + "cs8165c", + "cs8265c", + "cs8365c", + "cs8465c", + "ita-c", + "ita-e", + "ita-f", + "ita-ef", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "usb-a", + "usb-b", + "usb-c", + "usb-mini-a", + "usb-mini-b", + "usb-micro-a", + "usb-micro-b", + "usb-micro-ab", + "usb-3-b", + "usb-3-micro-b", + "dc-terminal", + "saf-d-grid", + "neutrik-powercon-20", + "neutrik-powercon-32", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + } + } + }, + "power-outlet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "iec-60320-c5", + "iec-60320-c7", + "iec-60320-c13", + "iec-60320-c15", + "iec-60320-c19", + "iec-60320-c21", + "iec-60309-p-n-e-4h", + "iec-60309-p-n-e-6h", + "iec-60309-p-n-e-9h", + "iec-60309-2p-e-4h", + "iec-60309-2p-e-6h", + "iec-60309-2p-e-9h", + "iec-60309-3p-e-4h", + "iec-60309-3p-e-6h", + "iec-60309-3p-e-9h", + "iec-60309-3p-n-e-4h", + "iec-60309-3p-n-e-6h", + "iec-60309-3p-n-e-9h", + "iec-60906-1", + "nbr-14136-10a", + "nbr-14136-20a", + "nema-1-15r", + "nema-5-15r", + "nema-5-20r", + "nema-5-30r", + "nema-5-50r", + "nema-6-15r", + "nema-6-20r", + "nema-6-30r", + "nema-6-50r", + "nema-10-30r", + "nema-10-50r", + "nema-14-20r", + "nema-14-30r", + "nema-14-50r", + "nema-14-60r", + "nema-15-15r", + "nema-15-20r", + "nema-15-30r", + "nema-15-50r", + "nema-15-60r", + "nema-l1-15r", + "nema-l5-15r", + "nema-l5-20r", + "nema-l5-30r", + "nema-l5-50r", + "nema-l6-15r", + "nema-l6-20r", + "nema-l6-30r", + "nema-l6-50r", + "nema-l10-30r", + "nema-l14-20r", + "nema-l14-30r", + "nema-l14-50r", + "nema-l14-60r", + "nema-l15-20r", + "nema-l15-30r", + "nema-l15-50r", + "nema-l15-60r", + "nema-l21-20r", + "nema-l21-30r", + "nema-l22-30r", + "CS6360C", + "CS6364C", + "CS8164C", + "CS8264C", + "CS8364C", + "CS8464C", + "ita-e", + "ita-f", + "ita-g", + "ita-h", + "ita-i", + "ita-j", + "ita-k", + "ita-l", + "ita-m", + "ita-n", + "ita-o", + "ita-multistandard", + "usb-a", + "usb-micro-b", + "usb-c", + "dc-terminal", + "hdot-cx", + "saf-d-grid", + "neutrik-powercon-20a", + "neutrik-powercon-32a", + "neutrik-powercon-true1", + "neutrik-powercon-true1-top", + "ubiquiti-smartpower", + "hardwired", + "other" + ] + }, + "feed-leg": { + "type": "string", + "enum": [ + "A", + "B", + "C" + ] + } + } + }, + "interface": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "virtual", + "bridge", + "lag", + "100base-fx", + "100base-lfx", + "100base-tx", + "100base-t1", + "1000base-t", + "2.5gbase-t", + "5gbase-t", + "10gbase-t", + "10gbase-cx4", + "1000base-x-gbic", + "1000base-x-sfp", + "10gbase-x-sfpp", + "10gbase-x-xfp", + "10gbase-x-xenpak", + "10gbase-x-x2", + "25gbase-x-sfp28", + "50gbase-x-sfp56", + "40gbase-x-qsfpp", + "50gbase-x-sfp28", + "100gbase-x-cfp", + "100gbase-x-cfp2", + "200gbase-x-cfp2", + "100gbase-x-cfp4", + "100gbase-x-cxp", + "100gbase-x-cpak", + "100gbase-x-dsfp", + "100gbase-x-sfpdd", + "100gbase-x-qsfp28", + "100gbase-x-qsfpdd", + "200gbase-x-qsfp56", + "200gbase-x-qsfpdd", + "400gbase-x-qsfpdd", + "400gbase-x-osfp", + "400gbase-x-cdfp", + "400gbase-x-cfp8", + "800gbase-x-qsfpdd", + "800gbase-x-osfp", + "1000base-kx", + "10gbase-kr", + "10gbase-kx4", + "25gbase-kr", + "40gbase-kr4", + "50gbase-kr", + "100gbase-kp4", + "100gbase-kr2", + "100gbase-kr4", + "ieee802.11a", + "ieee802.11g", + "ieee802.11n", + "ieee802.11ac", + "ieee802.11ad", + "ieee802.11ax", + "ieee802.11ay", + "ieee802.15.1", + "other-wireless", + "gsm", + "cdma", + "lte", + "sonet-oc3", + "sonet-oc12", + "sonet-oc48", + "sonet-oc192", + "sonet-oc768", + "sonet-oc1920", + "sonet-oc3840", + "1gfc-sfp", + "2gfc-sfp", + "4gfc-sfp", + "8gfc-sfpp", + "16gfc-sfpp", + "32gfc-sfp28", + "64gfc-qsfpp", + "128gfc-qsfp28", + "infiniband-sdr", + "infiniband-ddr", + "infiniband-qdr", + "infiniband-fdr10", + "infiniband-fdr", + "infiniband-edr", + "infiniband-hdr", + "infiniband-ndr", + "infiniband-xdr", + "t1", + "e1", + "t3", + "e3", + "xdsl", + "docsis", + "gpon", + "xg-pon", + "xgs-pon", + "ng-pon2", + "epon", + "10g-epon", + "cisco-stackwise", + "cisco-stackwise-plus", + "cisco-flexstack", + "cisco-flexstack-plus", + "cisco-stackwise-80", + "cisco-stackwise-160", + "cisco-stackwise-320", + "cisco-stackwise-480", + "cisco-stackwise-1t", + "juniper-vcp", + "extreme-summitstack", + "extreme-summitstack-128", + "extreme-summitstack-256", + "extreme-summitstack-512", + "other" + ] + }, + "poe_mode": { + "type": "string", + "enum": [ + "pd", + "pse" + ] + }, + "poe_type": { + "type": "string", + "enum": [ + "type1-ieee802.3af", + "type2-ieee802.3at", + "type3-ieee802.3bt", + "type4-ieee802.3bt", + "passive-24v-2pair", + "passive-24v-4pair", + "passive-48v-2pair", + "passive-48v-4pair" + ] + } + } + }, + "front-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + }, + "rear-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "8p8c", + "8p6c", + "8p4c", + "8p2c", + "6p6c", + "6p4c", + "6p2c", + "4p4c", + "4p2c", + "gg45", + "tera-4p", + "tera-2p", + "tera-1p", + "110-punch", + "bnc", + "f", + "n", + "mrj21", + "fc", + "lc", + "lc-pc", + "lc-upc", + "lc-apc", + "lsh", + "lsh-pc", + "lsh-upc", + "lsh-apc", + "lx5", + "lx5-pc", + "lx5-upc", + "lx5-apc", + "mpo", + "mtrj", + "sc", + "sc-pc", + "sc-upc", + "sc-apc", + "st", + "cs", + "sn", + "sma-905", + "sma-906", + "urm-p2", + "urm-p4", + "urm-p8", + "splice", + "other" + ] + } + } + } + } +} diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 000948ee7..68b777111 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -70,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above). +### Rebuild the Device Type Definition Schema + +Run the following command to update the device type definition validation schema: + +```nohighlight +./manage.py buildschema --write +``` + +This will automatically update the schema file at `contrib/generated_schema.json`. + ### Update Version and Changelog * Update the `VERSION` constant in `settings.py` to the new release version. diff --git a/netbox/dcim/management/commands/buildschema.py b/netbox/dcim/management/commands/buildschema.py new file mode 100644 index 000000000..44a0e95f2 --- /dev/null +++ b/netbox/dcim/management/commands/buildschema.py @@ -0,0 +1,62 @@ +import json +import os + +from django.conf import settings +from django.core.management.base import BaseCommand +from jinja2 import FileSystemLoader, Environment + +from dcim.choices import * + +TEMPLATE_FILENAME = 'devicetype_schema.jinja2' +OUTPUT_FILENAME = 'contrib/generated_schema.json' + +CHOICES_MAP = { + 'airflow_choices': DeviceAirflowChoices, + 'weight_unit_choices': WeightUnitChoices, + 'subdevice_role_choices': SubdeviceRoleChoices, + 'console_port_type_choices': ConsolePortTypeChoices, + 'console_server_port_type_choices': ConsolePortTypeChoices, + 'power_port_type_choices': PowerPortTypeChoices, + 'power_outlet_type_choices': PowerOutletTypeChoices, + 'power_outlet_feedleg_choices': PowerOutletFeedLegChoices, + 'interface_type_choices': InterfaceTypeChoices, + 'interface_poe_mode_choices': InterfacePoEModeChoices, + 'interface_poe_type_choices': InterfacePoETypeChoices, + 'front_port_type_choices': PortTypeChoices, + 'rear_port_type_choices': PortTypeChoices, +} + + +class Command(BaseCommand): + help = "Generate JSON schema for validating NetBox device type definitions" + + def add_arguments(self, parser): + parser.add_argument( + '--write', + action='store_true', + help="Write the generated schema to file" + ) + + def handle(self, *args, **kwargs): + # Initialize template + template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/') + template_env = Environment(loader=template_loader) + template = template_env.get_template(TEMPLATE_FILENAME) + + # Render template + context = { + key: json.dumps(choices.values()) + for key, choices in CHOICES_MAP.items() + } + rendered = template.render(**context) + + if kwargs['write']: + # $root/contrib/generated_schema.json + filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME) + with open(filename, mode='w', encoding='UTF-8') as f: + f.write(json.dumps(json.loads(rendered), indent=4)) + f.write('\n') + f.close() + self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}.")) + else: + self.stdout.write(rendered) diff --git a/netbox/templates/extras/schema/devicetype_schema.jinja2 b/netbox/templates/extras/schema/devicetype_schema.jinja2 new file mode 100644 index 000000000..b08ab24de --- /dev/null +++ b/netbox/templates/extras/schema/devicetype_schema.jinja2 @@ -0,0 +1,93 @@ +{ + "type": "object", + "additionalProperties": false, + "definitions": { + "airflow": { + "type": "string", + "enum": {{ airflow_choices }} + }, + "weight-unit": { + "type": "string", + "enum": {{ weight_unit_choices }} + }, + "subdevice-role": { + "type": "string", + "enum": {{ subdevice_role_choices }} + }, + "console-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ console_port_type_choices }} + } + } + }, + "console-server-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ console_server_port_type_choices }} + } + } + }, + "power-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ power_port_type_choices }} + } + } + }, + "power-outlet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ power_outlet_type_choices }} + }, + "feed-leg": { + "type": "string", + "enum": {{ power_outlet_feedleg_choices }} + } + } + }, + "interface": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ interface_type_choices }} + }, + "poe_mode": { + "type": "string", + "enum": {{ interface_poe_mode_choices }} + }, + "poe_type": { + "type": "string", + "enum": {{ interface_poe_type_choices }} + } + } + }, + "front-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ front_port_type_choices }} + } + } + }, + "rear-port": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": {{ rear_port_type_choices}} + } + } + } + } +} From 8593715149d94db3416be0de0b50a8ac1c4165a9 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 11 Aug 2023 22:27:48 +0700 Subject: [PATCH 37/53] 13319 add documentation for internationalization (#13330) * 13319 add documentation for internationalization * 13319 add verbose name to model * 13319 fix typo * Flesh out developer doc for i18n --------- Co-authored-by: Jeremy Stretch --- docs/development/internationalization.md | 123 +++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 124 insertions(+) create mode 100644 docs/development/internationalization.md diff --git a/docs/development/internationalization.md b/docs/development/internationalization.md new file mode 100644 index 000000000..bdc7cbdaa --- /dev/null +++ b/docs/development/internationalization.md @@ -0,0 +1,123 @@ +# Internationalization + +Beginning with NetBox v4.0, NetBox will leverage [Django's automatic translation](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) to support languages other than English. This page details the areas of the project which require special attention to ensure functioning translation support. Briefly, these include: + +* The `verbose_name` and `verbose_name_plural` Meta attributes for each model +* The `verbose_name` and (if defined) `help_text` for each model field +* The `label` for each form field +* Headers for `fieldsets` on each form class +* The `verbose_name` for each table column +* All human-readable strings within templates must be wrapped with `{% trans %}` or `{% blocktrans %}` + +The rest of this document elaborates on each of the items above. + +## General Guidance + +* Wrap human-readable strings with Django's `gettext()` or `gettext_lazy()` utility functions to enable automatic translation. Generally, `gettext_lazy()` is preferred (and sometimes required) to defer translation until the string is displayed. + +* By convention, the preferred translation function is typically imported as an underscore (`_`) to minimize boilerplate code. Thus, you will often see translation as e.g. `_("Some text")`. It is still an option to import and use alternative translation functions (e.g. `pgettext()` and `ngettext()`) normally as needed. + +* Avoid passing markup and other non-natural language where possible. Everything wrapped by a translation function gets exported to a messages file for translation by a human. + +* Where the intended meaning of the translated string may not be obvious, use `pgettext()` or `pgettext_lazy()` to include assisting context for the translator. For example: + + ```python + # Context, string + pgettext("month name", "May") + ``` + +* **Format strings do not support translation.** Avoid "f" strings for messages that must support translation. Instead, use `format()` to accomplish variable replacement: + + ```python + # Translation will not work + f"There are {count} objects" + + # Do this instead + "There are {count} objects".format(count=count) + ``` + +## Models + +1. Import `gettext_lazy` as `_`. +2. Ensure both `verbose_name` and `verbose_name_plural` are defined under the model's `Meta` class and wrapped with the `gettext_lazy()` shortcut. +3. Ensure each model field specifies a `verbose_name` wrapped with `gettext_lazy()`. +4. Ensure any `help_text` attributes on model fields are also wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class Circuit(PrimaryModel): + commit_rate = models.PositiveIntegerField( + ... + verbose_name=_('commit rate (Kbps)'), + help_text=_("Committed rate") + ) + + class Meta: + verbose_name = _('circuit') + verbose_name_plural = _('circuits') +``` + +## Forms + +1. Import `gettext_lazy` as `_`. +2. All form fields must specify a `label` wrapped with `gettext_lazy()`. +3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class CircuitBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + ... + ) + + fieldsets = ( + (_('Circuit'), ('provider', 'type', 'status', 'description')), + ) +``` + +## Tables + +1. Import `gettext_lazy` as `_`. +2. All table columns must specify a `verbose_name` wrapped with `gettext_lazy()`. + +```python +from django.utils.translation import gettext_lazy as _ + +class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): + provider = tables.Column( + verbose_name=_('Provider'), + ... + ) +``` + +## Templates + +1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template. +2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings. +3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. +4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps. + +``` +{% load i18n %} + +{# A short string #} +
{% trans "Circuit List" %}
+ +{# A longer string with a context variable #} +{% blocktrans with count=object.circuits.count %} + There are {count} circuits. Would you like to continue? +{% endblocktrans %} +``` + +!!! warning + The `{% blocktrans %}` tag supports only **limited variable replacement**, comparable to the `format()` method on Python strings. It does not permit access to object attributes or the use of other template tags or filters inside it. Ensure that any necessary context is passed as simple variables. + +!!! info + The `{% trans %}` and `{% blocktrans %}` support the inclusion of contextual hints for translators using the `context` argument: + + ```nohighlight + {% trans "May" context "month name" %} + ``` diff --git a/mkdocs.yml b/mkdocs.yml index 2203039f3..cc16434de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -271,6 +271,7 @@ nav: - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' - Web UI: 'development/web-ui.md' + - Internationalization: 'development/internationalization.md' - Release Checklist: 'development/release-checklist.md' - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: From 5de9d3f15f4f5bc82ebbe5f650dcd528fe9bf250 Mon Sep 17 00:00:00 2001 From: kkthxbye <400797+kkthxbye-code@users.noreply.github.com> Date: Fri, 11 Aug 2023 17:53:16 +0200 Subject: [PATCH 38/53] Fixes #12639 - Make sure name expansions throws a validation error on decrementing ranges (#13326) * Fixes #12639 - Make sure name expansions throws a validation error on decrementing ranges * Fix pep8 * Also fail on equal start & end values --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/object_create.py | 5 ++++- netbox/utilities/forms/utils.py | 7 +++++++ netbox/utilities/tests/test_forms.py | 5 +++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 9589ab533..f37edee0a 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -52,7 +52,10 @@ class ComponentCreateForm(forms.Form): super().clean() # Validate that all replication fields generate an equal number of values - pattern_count = len(self.cleaned_data[self.replication_fields[0]]) + if not (patterns := self.cleaned_data.get(self.replication_fields[0])): + return + + pattern_count = len(patterns) for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) if self.cleaned_data[field_name] and value_count != pattern_count: diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 5100b1714..4d737f163 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -60,6 +60,9 @@ def parse_alphanumeric_range(string): except ValueError: begin, end = dash_range, dash_range if begin.isdigit() and end.isdigit(): + if int(begin) >= int(end): + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + for n in list(range(int(begin), int(end) + 1)): values.append(n) else: @@ -71,6 +74,10 @@ def parse_alphanumeric_range(string): # Not a valid range (more than a single character) if not len(begin) == len(end) == 1: raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + + if ord(begin) >= ord(end): + raise forms.ValidationError(f'Range "{dash_range}" is invalid.') + for n in list(range(ord(begin), ord(end) + 1)): values.append(chr(n)) return values diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index b8cff2996..79ba3f4d8 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -264,8 +264,9 @@ class ExpandAlphanumeric(TestCase): self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), []) def test_invalid_range_bounds(self): - self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-8]a')), []) - self.assertEqual(sorted(expand_alphanumeric_pattern('r[b-a]a')), []) + with self.assertRaises(forms.ValidationError): + sorted(expand_alphanumeric_pattern('r[9-8]a')) + sorted(expand_alphanumeric_pattern('r[b-a]a')) def test_invalid_range_len(self): with self.assertRaises(forms.ValidationError): From be3f48c6778414a9f129d2bcef8bfbe259d781c1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 14 Aug 2023 13:29:11 +0530 Subject: [PATCH 39/53] Fixed spelling for Attributes #13460 --- netbox/ipam/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 53fecfe2f..f00082863 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -253,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( (None, ('q', 'filter_id', 'tag')), - ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), + ('Attributes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) family = forms.ChoiceField( From b5837707652da3c18739b10efbf49a5fbc2d7762 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 14 Aug 2023 08:51:16 -0400 Subject: [PATCH 40/53] Fixes #13451: Disable table ordering for custom link columns --- docs/release-notes/version-3.5.md | 1 + netbox/netbox/tables/columns.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index fe0832c3b..30ffef393 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -19,6 +19,7 @@ * [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports * [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms * [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox +* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns --- diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9ef327026..399b3c184 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -504,9 +504,9 @@ class CustomLinkColumn(tables.Column): """ def __init__(self, customlink, *args, **kwargs): self.customlink = customlink - kwargs['accessor'] = Accessor('pk') - if 'verbose_name' not in kwargs: - kwargs['verbose_name'] = customlink.name + kwargs.setdefault('accessor', Accessor('pk')) + kwargs.setdefault('orderable', False) + kwargs.setdefault('verbose_name', customlink.name) super().__init__(*args, **kwargs) From b9b9c065cc3723bb390b65e8ac1519beb8104e9e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 14 Aug 2023 08:55:47 -0400 Subject: [PATCH 41/53] Changelog for #10030, #11578, #12639 --- docs/release-notes/version-3.5.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 30ffef393..cc8f61802 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,6 +4,7 @@ ### Enhancements +* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release * [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import * [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI * [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type @@ -13,6 +14,8 @@ ### Bug Fixes +* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints +* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects * [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links * [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted * [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view From ea107b6b86015f3fd97995bb7ea738a0d95a83c1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 14 Aug 2023 18:57:26 +0530 Subject: [PATCH 42/53] adds object view to allow changelog page to be opened #13463 --- netbox/templates/extras/imageattachment.html | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 netbox/templates/extras/imageattachment.html diff --git a/netbox/templates/extras/imageattachment.html b/netbox/templates/extras/imageattachment.html new file mode 100644 index 000000000..1968344cc --- /dev/null +++ b/netbox/templates/extras/imageattachment.html @@ -0,0 +1,4 @@ +{% extends 'generic/object.html' %} + +{% block tabs %} +{% endblock %} From 752e26c7dea021c8a014b60468dde7500dc10b5f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 15 Aug 2023 01:13:28 +0530 Subject: [PATCH 43/53] Adds config template to vm model (#13450) * adds config template to vm model #12461 * Add translation tags; collapse config data * i18n cleanup * Establish parity with DeviceRenderConfigView * Move config_template field to RenderConfigMixin --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/migrations/0170_configtemplate.py | 2 +- netbox/dcim/models/devices.py | 29 +++----- netbox/dcim/models/mixins.py | 29 ++++++++ .../virtualization/virtualmachine.html | 4 ++ .../virtualmachine/render_config.html | 70 +++++++++++++++++++ netbox/virtualization/api/serializers.py | 5 +- netbox/virtualization/filtersets.py | 5 ++ netbox/virtualization/forms/bulk_edit.py | 8 ++- netbox/virtualization/forms/bulk_import.py | 10 ++- netbox/virtualization/forms/filtersets.py | 8 ++- netbox/virtualization/forms/model_forms.py | 9 ++- .../0036_virtualmachine_config_template.py | 20 ++++++ .../virtualization/models/virtualmachines.py | 3 +- .../virtualization/tables/virtualmachines.py | 6 +- netbox/virtualization/views.py | 52 ++++++++++++++ 15 files changed, 232 insertions(+), 28 deletions(-) create mode 100644 netbox/templates/virtualization/virtualmachine/render_config.html create mode 100644 netbox/virtualization/migrations/0036_virtualmachine_config_template.py diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py index b1aac0ad2..f9508424d 100644 --- a/netbox/dcim/migrations/0170_configtemplate.py +++ b/netbox/dcim/migrations/0170_configtemplate.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='config_template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), ), migrations.AddField( model_name='devicerole', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6b8e92743..857251caf 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -24,7 +24,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.tracking import TrackingModelMixin from .device_components import * -from .mixins import WeightMixin +from .mixins import RenderConfigMixin, WeightMixin __all__ = ( @@ -525,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None): interface.save() -class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextModel, TrackingModelMixin): +class Device( + ContactsMixin, + ImageAttachmentsMixin, + RenderConfigMixin, + ConfigContextModel, + TrackingModelMixin, + PrimaryModel +): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -686,13 +693,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo validators=[MaxValueValidator(255)], help_text=_('Virtual chassis master election priority') ) - config_template = models.ForeignKey( - to='extras.ConfigTemplate', - on_delete=models.PROTECT, - related_name='devices', - blank=True, - null=True - ) latitude = models.DecimalField( verbose_name=_('latitude'), max_digits=8, @@ -1070,17 +1070,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo def interfaces_count(self): return self.vc_interfaces().count() - def get_config_template(self): - """ - Return the appropriate ConfigTemplate (if any) for this Device. - """ - if self.config_template: - return self.config_template - if self.role.config_template: - return self.role.config_template - if self.platform and self.platform.config_template: - return self.platform.config_template - def get_vc_master(self): """ If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index f787c8e97..95f6d41fe 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from utilities.utils import to_grams +__all__ = ( + 'RenderConfigMixin', + 'WeightMixin', +) + class WeightMixin(models.Model): weight = models.DecimalField( @@ -44,3 +49,27 @@ class WeightMixin(models.Model): # Validate weight and weight_unit if self.weight and not self.weight_unit: raise ValidationError(_("Must specify a unit when setting a weight")) + + +class RenderConfigMixin(models.Model): + config_template = models.ForeignKey( + to='extras.ConfigTemplate', + on_delete=models.PROTECT, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def get_config_template(self): + """ + Return the appropriate ConfigTemplate (if any) for this Device. + """ + if self.config_template: + return self.config_template + if self.role.config_template: + return self.role.config_template + if self.platform and self.platform.config_template: + return self.platform.config_template diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 04e038b92..27f5ea114 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -43,6 +43,10 @@ {{ object.tenant|linkify|placeholder }} + + {% trans "Config Template" %} + {{ object.config_template|linkify|placeholder }} + {% trans "Primary IPv4" %} diff --git a/netbox/templates/virtualization/virtualmachine/render_config.html b/netbox/templates/virtualization/virtualmachine/render_config.html new file mode 100644 index 000000000..7b638199b --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine/render_config.html @@ -0,0 +1,70 @@ +{% extends 'virtualization/virtualmachine/base.html' %} +{% load static %} +{% load i18n %} + +{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Config Template" %}
+
+ + + + + + + + + + + + + +
{% trans "Config Template" %}{{ config_template|linkify|placeholder }}
{% trans "Data Source" %}{{ config_template.data_file.source|linkify|placeholder }}
{% trans "Data File" %}{{ config_template.data_file|linkify|placeholder }}
+
+
+
+
+
+
+
+
+

+ +

+
+
+
{{ context_data|pprint }}
+
+
+
+
+
+
+
+
+
+
+
+
+ +
{% trans "Rendered Config" %}
+
+ {% if config_template %} +
{{ rendered_config }}
+ {% else %} +
{% trans "No configuration template found" %}
+ {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 693bb362f..c9fa559aa 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -5,6 +5,7 @@ from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, ) from dcim.choices import InterfaceModeChoices +from extras.api.nested_serializers import NestedConfigTemplateSerializer from ipam.api.nested_serializers import ( NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, ) @@ -79,6 +80,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) # Counter fields interface_count = serializers.IntegerField(read_only=True) @@ -88,7 +90,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count', + 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'interface_count', ] validators = [] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index cf716ca32..571dbe64b 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext as _ from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet +from extras.models import ConfigTemplate from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -228,6 +229,10 @@ class VirtualMachineFilterSet( method='_has_primary_ip', label=_('Has a primary IP'), ) + config_template_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigTemplate.objects.all(), + label=_('Config template (ID)'), + ) class Meta: model = VirtualMachine diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index cc281a4f7..a33ffac53 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -174,12 +175,17 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) comments = CommentField() model = VirtualMachine fieldsets = ( (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), - (_('Resources'), ('vcpus', 'memory', 'disk')) + (_('Resources'), ('vcpus', 'memory', 'disk')), + ('Configuration', ('config_template',)), ) nullable_fields = ( 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 19f718f03..04fe2d7ae 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices from dcim.models import Device, DeviceRole, Platform, Site +from extras.models import ConfigTemplate from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant @@ -123,12 +124,19 @@ class VirtualMachineImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned platform') ) + config_template = CSVModelChoiceField( + queryset=ConfigTemplate.objects.all(), + to_field_name='name', + required=False, + label=_('Config template'), + help_text=_('Config template') + ) class Meta: model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'comments', 'tags', + 'description', 'config_template', 'comments', 'tags', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index cd1269645..99ac0cb77 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm +from extras.models import ConfigTemplate from ipam.models import L2VPN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm @@ -93,7 +94,7 @@ class VirtualMachineFilterForm( (None, ('q', 'filter_id', 'tag')), (_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), + (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'local_context_data')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) @@ -170,6 +171,11 @@ class VirtualMachineFilterForm( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + config_template_id = DynamicModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 0c8c98f9f..21dbc895a 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.forms.common import InterfaceCommonForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup +from extras.models import ConfigTemplate from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -205,13 +206,18 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) comments = CommentField() fieldsets = ( (_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')), (_('Site/Cluster'), ('site', 'cluster', 'device')), (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Management'), ('platform', 'primary_ip4', 'primary_ip6')), + (_('Management'), ('platform', 'primary_ip4', 'primary_ip6', 'config_template')), (_('Resources'), ('vcpus', 'memory', 'disk')), (_('Config Context'), ('local_context_data',)), ) @@ -221,6 +227,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fields = [ 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data', + 'config_template', ] def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/migrations/0036_virtualmachine_config_template.py b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py new file mode 100644 index 000000000..0456eea81 --- /dev/null +++ b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.10 on 2023-08-11 17:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('virtualization', '0035_virtualmachine_interface_count'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 1cacd8adc..eb6c2a8b0 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from dcim.models import BaseInterface +from dcim.models.mixins import RenderConfigMixin from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config @@ -25,7 +26,7 @@ __all__ = ( ) -class VirtualMachine(ContactsMixin, PrimaryModel, ConfigContextModel): +class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel): """ A virtual machine which runs inside a Cluster. """ diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index cece6f092..f8473df1e 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -84,13 +84,17 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) interface_count = tables.Column( verbose_name=_('Interfaces') ) + config_template = tables.Column( + verbose_name=_('Config Template'), + linkify=True + ) class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', - 'contacts', 'tags', 'created', 'last_updated', + 'config_template', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a4474610a..9c7748cbd 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,11 +1,14 @@ +import traceback from collections import defaultdict from django.contrib import messages from django.db import transaction from django.db.models import Prefetch, Sum +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ +from jinja2.exceptions import TemplateError from dcim.filtersets import DeviceFilterSet from dcim.models import Device @@ -389,6 +392,55 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): ) +@register_model_view(VirtualMachine, 'render-config') +class VirtualMachineRenderConfigView(generic.ObjectView): + queryset = VirtualMachine.objects.all() + template_name = 'virtualization/virtualmachine/render_config.html' + tab = ViewTab( + label=_('Render Config'), + permission='extras.view_configtemplate', + weight=2100 + ) + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + # If a direct export has been requested, return the rendered template content as a + # downloadable file. + if request.GET.get('export'): + response = HttpResponse(context['rendered_config'], content_type='text') + filename = f"{instance.name or 'config'}.txt" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + return render(request, self.get_template_name(), { + 'object': instance, + 'tab': self.tab, + **context, + }) + + def get_extra_context(self, request, instance): + # Compile context data + context_data = instance.get_config_context() + context_data.update({'virtualmachine': instance}) + + # Render the config template + rendered_config = None + if config_template := instance.get_config_template(): + try: + rendered_config = config_template.render(context=context_data) + except TemplateError as e: + messages.error(request, f"An error occurred while rendering the template: {e}") + rendered_config = traceback.format_exc() + + return { + 'config_template': config_template, + 'context_data': context_data, + 'rendered_config': rendered_config, + } + + @register_model_view(VirtualMachine, 'edit') class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all() From 892c10b1f077b5d5147dd408a5d06ee3e7c3d0f8 Mon Sep 17 00:00:00 2001 From: "Joel D. Tague" Date: Fri, 11 Aug 2023 13:36:34 -0400 Subject: [PATCH 44/53] feat: add 200Gbps & 400Gbps interface speed options --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 21bd3ed7e..ba722508a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1141,6 +1141,8 @@ class InterfaceSpeedChoices(ChoiceSet): (25000000, '25 Gbps'), (40000000, '40 Gbps'), (100000000, '100 Gbps'), + (200000000, '200 Gbps'), + (400000000, '400 Gbps'), ] From e61795d5c66c8c9121177a1ece0df71835ebe723 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Aug 2023 09:18:15 -0400 Subject: [PATCH 45/53] Release v3.5.8 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.5.md | 3 ++- netbox/netbox/settings.py | 2 +- requirements.txt | 10 +++++----- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 42a716ae7..b43968731 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.7 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index b04fda1b6..5df3069ba 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.7 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index cc8f61802..6d9ae5509 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,6 +1,6 @@ # NetBox v3.5 -## v3.5.8 (FUTURE) +## v3.5.8 (2023-08-15) ### Enhancements @@ -11,6 +11,7 @@ * [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table * [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses * [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page +* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form ### Bug Fixes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2744ba701..acad437fc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.8-dev' +VERSION = '3.5.8' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index f1235fa2c..2ea0f2522 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ bleach==6.0.0 -boto3==1.28.14 +boto3==1.28.26 Django==4.1.10 django-cors-headers==4.2.0 -django-debug-toolbar==4.1.0 +django-debug-toolbar==4.2.0 django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 @@ -16,7 +16,7 @@ django-taggit==4.0.0 django-timezone-field==5.1 djangorestframework==3.14.0 drf-spectacular==0.26.4 -drf-spectacular-sidecar==2023.7.1 +drf-spectacular-sidecar==2023.8.1 dulwich==0.21.5 feedparser==6.0.10 graphene-django==3.0.0 @@ -27,9 +27,9 @@ mkdocs-material==9.1.21 mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 Pillow==10.0.0 -psycopg2-binary==2.9.6 +psycopg2-binary==2.9.7 PyYAML==6.0.1 -sentry-sdk==1.28.1 +sentry-sdk==1.29.2 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3 From 1c9a8ec6bd0a4b803652a721cf81e91a0bdd5340 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Aug 2023 10:00:24 -0400 Subject: [PATCH 46/53] PRVB --- docs/release-notes/version-3.5.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 6d9ae5509..f7778275b 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,5 +1,9 @@ # NetBox v3.5 +## v3.5.9 (FUTURE) + +--- + ## v3.5.8 (2023-08-15) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index acad437fc..aace6745a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.8' +VERSION = '3.5.9-dev' # Hostname HOSTNAME = platform.node() From 0457520f5165ffd4d919f14e128371d81588c1cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Aug 2023 11:25:56 -0400 Subject: [PATCH 47/53] Changelog for #12461 --- docs/release-notes/version-3.6.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index a341a5464..73b8e269f 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -76,6 +76,7 @@ Tags may now be restricted to use with designated object types. Tags that have n * [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks * [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one * [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds +* [#12461](https://github.com/netbox-community/netbox/issues/12461) - Add config template rendering for virtual machines * [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context * [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments * [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API From b96e437e2b6eb0b6d7a3c47a7ef71c15774805be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Aug 2023 10:10:31 -0400 Subject: [PATCH 48/53] #8248: Add bookmarks widget to default dashboard --- netbox/extras/constants.py | 50 +++++++++++-------- .../extras/dashboard/widgets/bookmarks.html | 7 +++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 6d9f78001..48b44fb45 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -19,6 +19,13 @@ WEBHOOK_EVENT_TYPES = { # Dashboard DEFAULT_DASHBOARD = [ + { + 'widget': 'extras.BookmarksWidget', + 'width': 4, + 'height': 5, + 'title': 'Bookmarks', + 'color': 'orange', + }, { 'widget': 'extras.ObjectCountsWidget', 'width': 4, @@ -32,22 +39,6 @@ DEFAULT_DASHBOARD = [ ] } }, - { - 'widget': 'extras.ObjectCountsWidget', - 'width': 4, - 'height': 3, - 'title': 'IPAM', - 'config': { - 'models': [ - 'ipam.vrf', - 'ipam.aggregate', - 'ipam.prefix', - 'ipam.iprange', - 'ipam.ipaddress', - 'ipam.vlan', - ] - } - }, { 'widget': 'extras.NoteWidget', 'width': 4, @@ -65,13 +56,16 @@ DEFAULT_DASHBOARD = [ { 'widget': 'extras.ObjectCountsWidget', 'width': 4, - 'height': 2, - 'title': 'Circuits', + 'height': 3, + 'title': 'IPAM', 'config': { 'models': [ - 'circuits.provider', - 'circuits.circuit', - 'circuits.providernetwork', + 'ipam.vrf', + 'ipam.aggregate', + 'ipam.prefix', + 'ipam.iprange', + 'ipam.ipaddress', + 'ipam.vlan', ] } }, @@ -86,6 +80,20 @@ DEFAULT_DASHBOARD = [ 'cache_timeout': 14400, } }, + { + 'widget': 'extras.ObjectCountsWidget', + 'width': 4, + 'height': 3, + 'title': 'Circuits', + 'config': { + 'models': [ + 'circuits.provider', + 'circuits.circuit', + 'circuits.providernetwork', + 'circuits.provideraccount', + ] + } + }, { 'widget': 'extras.ObjectCountsWidget', 'width': 4, diff --git a/netbox/templates/extras/dashboard/widgets/bookmarks.html b/netbox/templates/extras/dashboard/widgets/bookmarks.html index 2189cc55f..e8638d20e 100644 --- a/netbox/templates/extras/dashboard/widgets/bookmarks.html +++ b/netbox/templates/extras/dashboard/widgets/bookmarks.html @@ -1,3 +1,5 @@ +{% load i18n %} + {% if bookmarks %}
{% for bookmark in bookmarks %} @@ -6,4 +8,9 @@ {% endfor %}
+{% else %} +

+ + {% blocktrans %}No bookmarks have been added yet.{% endblocktrans %} +

{% endif %} From b4acbb5e16ac145a9a1d085bf4f9adf949fee6ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Aug 2023 10:28:33 -0400 Subject: [PATCH 49/53] Closes #13439: Update API token documentation --- docs/configuration/security.md | 2 +- docs/integrations/rest-api.md | 17 ++++++++--------- docs/media/admin_ui_grant_permission.png | Bin 33454 -> 0 bytes docs/release-notes/version-3.6.md | 1 + 4 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 docs/media/admin_ui_grant_permission.png diff --git a/docs/configuration/security.md b/docs/configuration/security.md index a6dc4c5ce..2ae92285f 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -4,7 +4,7 @@ Default: True -If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. +If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. --- diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 9d6367b3e..03a34abf4 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -570,27 +570,26 @@ The NetBox REST API primarily employs token-based authentication. For convenienc A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. -!!! note - All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. +By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. -By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. - Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. -!!! warning "Restricting Token Retrieval" +!!! info "Restricting Token Retrieval" The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. +### Restricting Write Operations + +By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. + #### Client IP Restriction Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) #### Creating Tokens for Other Users -It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users. - -![Adding the grant action to a permission](../media/admin_ui_grant_permission.png) +It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users. !!! warning "Exercise Caution" The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise. @@ -627,7 +626,7 @@ When a token is used to authenticate a request, its `last_updated` time updated ### Initial Token Provisioning -Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. +Ideally, each user should provision his or her own API token(s) via the web UI. However, you may encounter a scenario where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. (Note that the user must have permission to create API tokens regardless of the interface used.) To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: diff --git a/docs/media/admin_ui_grant_permission.png b/docs/media/admin_ui_grant_permission.png deleted file mode 100644 index 2b82dcca200f77e34570e002a8e42ffd2d485bce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33454 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 Date: Wed, 16 Aug 2023 11:08:36 -0400 Subject: [PATCH 50/53] Closes #13412: Enable pagination of custom field choice set choices --- netbox/extras/views.py | 21 +++++++++++++++++++ .../extras/customfieldchoiceset.html | 8 ++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 193d8821b..a9636cc9e 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,6 +1,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType +from django.core.paginator import EmptyPage from django.db.models import Count, Q from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -18,6 +19,7 @@ from netbox.config import get_config, PARAMS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx +from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict @@ -89,6 +91,25 @@ class CustomFieldChoiceSetListView(generic.ObjectListView): class CustomFieldChoiceSetView(generic.ObjectView): queryset = CustomFieldChoiceSet.objects.all() + def get_extra_context(self, request, instance): + + # Paginate choices list + per_page = get_paginate_count(request) + try: + page_number = request.GET.get('page', 1) + except ValueError: + page_number = 1 + paginator = EnhancedPaginator(instance.choices, per_page) + try: + choices = paginator.page(page_number) + except EmptyPage: + choices = paginator.page(paginator.num_pages) + + return { + 'paginator': paginator, + 'choices': choices, + } + @register_model_view(CustomFieldChoiceSet, 'edit') class CustomFieldChoiceSetEditView(generic.ObjectEditView): diff --git a/netbox/templates/extras/customfieldchoiceset.html b/netbox/templates/extras/customfieldchoiceset.html index e77a8432d..7240a88bf 100644 --- a/netbox/templates/extras/customfieldchoiceset.html +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -55,18 +55,14 @@ Label - {% for value, label in object.choices|slice:":50" %} + {% for value, label in choices %} {{ value }} {{ label }} {% endfor %} - {% if object.choices|length > 50 %} - - (Additional choices not displayed) - - {% endif %} + {% include 'inc/paginator.html' with page=choices %} {% plugin_right_page object %} From 5709bc3b2b60597114555a8ed3f70ee4703a483f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Aug 2023 11:28:31 -0400 Subject: [PATCH 51/53] Release v3.6-beta2 --- docs/release-notes/version-3.6.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index b9fee9c6d..062e96556 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,6 +1,6 @@ # NetBox v3.6 -## v3.6-beta2 (FUTURE) +## v3.6-beta2 (2023-08-16) ### Bug Fixes From 229007082b262fb19b539525fb00e8ee48553977 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 18 Aug 2023 13:49:33 -0700 Subject: [PATCH 52/53] 13510 update docs run permission image --- docs/media/admin_ui_run_permission.png | Bin 8174 -> 21965 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/media/admin_ui_run_permission.png b/docs/media/admin_ui_run_permission.png index a7aaa79b8f985e42f0d7c276aa760b64ba2a13b9..9c5b3e733284c35f5b9885c0fde474cf1b1e4ab8 100644 GIT binary patch literal 21965 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 D^yc<7 literal 8174 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

pVPr^i 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 From 7d7e8127f5877d86024a026c7d2d1e479639a0b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Aug 2023 10:44:22 -0400 Subject: [PATCH 53/53] Fixes #13513: Prevent exception when rendering bookmarks widget for anonymous user --- netbox/extras/dashboard/widgets.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 3d6275f45..dcf83bc14 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -346,13 +346,16 @@ class BookmarksWidget(DashboardWidget): def render(self, request): from extras.models import Bookmark - bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) - if object_types := self.config.get('object_types'): - models = get_models_from_content_types(object_types) - conent_types = ContentType.objects.get_for_models(*models).values() - bookmarks = bookmarks.filter(object_type__in=conent_types) - if max_items := self.config.get('max_items'): - bookmarks = bookmarks[:max_items] + if request.user.is_anonymous: + bookmarks = list() + else: + bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by']) + if object_types := self.config.get('object_types'): + models = get_models_from_content_types(object_types) + conent_types = ContentType.objects.get_for_models(*models).values() + bookmarks = bookmarks.filter(object_type__in=conent_types) + if max_items := self.config.get('max_items'): + bookmarks = bookmarks[:max_items] return render_to_string(self.template_name, { 'bookmarks': bookmarks,