From 3d3c1c315b46a7926356301a7ad93fc0b5d69e95 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Apr 2024 16:15:32 -0400 Subject: [PATCH 1/8] Update documentation for the DEFAULT_LANGUAGE configuration parameter --- docs/configuration/system.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 806839778..28c09444b 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -16,10 +16,7 @@ BASE_PATH = 'netbox/' Default: `en-us` (US English) -Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) - -!!! note - Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release. +Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) --- From 94c31622acf4978720e9dda9592094ad5d5161b9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 18 Apr 2024 14:59:21 -0700 Subject: [PATCH 2/8] 15588 set readonly nullable fields as allow_null=True --- netbox/dcim/api/serializers.py | 8 ++++---- netbox/ipam/api/serializers.py | 10 +++++----- netbox/virtualization/api/serializers.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 053b3e9ea..ce3cf4d9c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -612,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer): required=False, allow_null=True ) - component = serializers.SerializerMethodField(read_only=True) + component = serializers.SerializerMethodField(read_only=True, allow_null=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: @@ -685,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer): ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) - primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) @@ -735,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer): - config_context = serializers.SerializerMethodField(read_only=True) + config_context = serializers.SerializerMethodField(read_only=True, allow_null=True) class Meta(DeviceSerializer.Meta): fields = [ @@ -1067,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer): required=False, allow_null=True ) - component = serializers.SerializerMethodField(read_only=True) + component = serializers.SerializerMethodField(read_only=True, allow_null=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 33aa55a93..8dca73d94 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer): Representation of a VLAN which does not exist in the database. """ vid = serializers.IntegerField(read_only=True) - group = NestedVLANGroupSerializer(read_only=True) + group = NestedVLANGroupSerializer(read_only=True, allow_null=True) def to_representation(self, instance): return { @@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer): """ Representation of a prefix which does not exist in the database. """ - family = serializers.IntegerField(read_only=True) + family = serializers.IntegerField(read_only=True, allow_null=True) prefix = serializers.CharField(read_only=True) - vrf = NestedVRFSerializer(read_only=True) + vrf = NestedVRFSerializer(read_only=True, allow_null=True) def to_representation(self, instance): if self.context.get('vrf'): @@ -429,9 +429,9 @@ class AvailableIPSerializer(serializers.Serializer): """ Representation of an IP address which does not exist in the database. """ - family = serializers.IntegerField(read_only=True) + family = serializers.IntegerField(read_only=True, allow_null=True) address = serializers.CharField(read_only=True) - vrf = NestedVRFSerializer(read_only=True) + vrf = NestedVRFSerializer(read_only=True, allow_null=True) description = serializers.CharField(required=False) def to_representation(self, instance): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 34e4037e9..a54643e62 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -76,7 +76,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) - primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=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) From c9de3128ca156000d70a38ff948c78603c02ea9a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Apr 2024 16:10:06 -0400 Subject: [PATCH 3/8] Fixes #15790: Fix live preview support for EventRule comments --- netbox/extras/forms/model_forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 8f9face41..70b7a78a4 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm): required=False, help_text=_('Enter parameters to pass to the action in JSON format.') ) + comments = CommentField() fieldsets = ( (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')), From 88facbafbb6a754cb5f48fb541005bdabda744b6 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 19 Apr 2024 14:09:55 -0700 Subject: [PATCH 4/8] 15761 filter IKE Proposals on IKE Policy detail view (#15766) * 15761 filter IKEAProposals on IKEAPolicy detail view * Add test for ike_policy filter --------- Co-authored-by: Jeremy Stretch --- netbox/vpn/filtersets.py | 11 +++++++++++ netbox/vpn/tests/test_filtersets.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 0647838a8..10f0834fb 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -136,6 +136,17 @@ class IKEProposalFilterSet(NetBoxModelFilterSet): group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices ) + ike_policy_id = django_filters.ModelMultipleChoiceFilter( + field_name='ike_policies', + queryset=IKEPolicy.objects.all(), + label=_('IKE policy (ID)'), + ) + ike_policy = django_filters.ModelMultipleChoiceFilter( + field_name='ike_policies__name', + queryset=IKEPolicy.objects.all(), + to_field_name='name', + label=_('IKE policy (name)'), + ) class Meta: model = IKEProposal diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index d4e80750d..f11e63f10 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -331,6 +331,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests): ) IKEProposal.objects.bulk_create(ike_proposals) + ike_policies = ( + IKEPolicy(name='IKE Policy 1'), + IKEPolicy(name='IKE Policy 2'), + IKEPolicy(name='IKE Policy 3'), + ) + IKEPolicy.objects.bulk_create(ike_policies) + ike_policies[0].proposals.add(ike_proposals[0]) + ike_policies[1].proposals.add(ike_proposals[1]) + ike_policies[2].proposals.add(ike_proposals[2]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -369,6 +379,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'sa_lifetime': [1000, 2000]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ike_policy(self): + ike_policies = IKEPolicy.objects.all()[:2] + params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IKEPolicy.objects.all() From 90d0104359a07f25185fc238d820005780ace6c4 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 22 Apr 2024 05:22:53 -0700 Subject: [PATCH 5/8] 15541 Add component selector to InventoryItemTemplate (#15759) * 15541 make inventoryitemtemplateform match inventoryitemform * 15541 set tab active --- netbox/dcim/forms/model_forms.py | 105 ++++++++++++++++-- netbox/dcim/views.py | 2 + .../dcim/inventoryitemtemplate_edit.html | 104 +++++++++++++++++ 3 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 netbox/templates/dcim/inventoryitemtemplate_edit.html diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6773bc55f..cee8fcfba 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -976,21 +976,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm): queryset=Manufacturer.objects.all(), required=False ) - component_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + # Assigned component selectors + consoleporttemplate = DynamicModelChoiceField( + queryset=ConsolePortTemplate.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Console port template') ) - component_id = forms.IntegerField( + consoleserverporttemplate = DynamicModelChoiceField( + queryset=ConsoleServerPortTemplate.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Console server port template') + ) + frontporttemplate = DynamicModelChoiceField( + queryset=FrontPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Front port template') + ) + interfacetemplate = DynamicModelChoiceField( + queryset=InterfaceTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Interface template') + ) + poweroutlettemplate = DynamicModelChoiceField( + queryset=PowerOutletTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Power outlet template') + ) + powerporttemplate = DynamicModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Power port template') + ) + rearporttemplate = DynamicModelChoiceField( + queryset=RearPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Rear port template') ) fieldsets = ( (None, ( 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', - 'component_type', 'component_id', )), ) @@ -998,9 +1044,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm): model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', - 'component_type', 'component_id', ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + component_type = initial.get('component_type') + component_id = initial.get('component_id') + + # Used for picking the default active tab for component selection + self.no_component = True + + if instance: + # When editing set the initial value for component selection + for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS): + if type(instance.component) is component_model.model_class(): + initial[component_model.model] = instance.component + self.no_component = False + break + elif component_type and component_id: + # When adding the InventoryItem from a component page + if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first(): + if component := content_type.model_class().objects.filter(pk=component_id).first(): + initial[content_type.model] = component + self.no_component = False + + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Handle object assignment + selected_objects = [ + field for field in ( + 'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', + 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate' + ) if self.cleaned_data[field] + ] + if len(selected_objects) > 1: + raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component.")) + elif selected_objects: + self.instance.component = self.cleaned_data[selected_objects[0]] + else: + self.instance.component = None + # # Device components diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d0e92ff56..ce4bb5750 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView): queryset = InventoryItemTemplate.objects.all() form = forms.InventoryItemTemplateCreateForm model_form = forms.InventoryItemTemplateForm + template_name = 'dcim/inventoryitemtemplate_edit.html' def alter_object(self, instance, request): # Set component (if any) @@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView): class InventoryItemTemplateEditView(generic.ObjectEditView): queryset = InventoryItemTemplate.objects.all() form = forms.InventoryItemTemplateForm + template_name = 'dcim/inventoryitemtemplate_edit.html' @register_model_view(InventoryItemTemplate, 'delete') diff --git a/netbox/templates/dcim/inventoryitemtemplate_edit.html b/netbox/templates/dcim/inventoryitemtemplate_edit.html new file mode 100644 index 000000000..d3ac58e25 --- /dev/null +++ b/netbox/templates/dcim/inventoryitemtemplate_edit.html @@ -0,0 +1,104 @@ +{% extends 'generic/object_edit.html' %} +{% load static %} +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + +{% block form %} +
+
+
{% trans "Inventory Item" %}
+
+ {% render_field form.device_type %} + {% render_field form.parent %} + {% render_field form.name %} + {% render_field form.label %} + {% render_field form.role %} + {% render_field form.description %} +
+ +
+
+
{% trans "Hardware" %}
+
+ {% render_field form.manufacturer %} + {% render_field form.part_id %} +
+ +
+
+
{% trans "Component Assignment" %}
+
+
+ +
+
+
+ {% render_field form.consoleporttemplate %} +
+
+ {% render_field form.consoleserverporttemplate %} +
+
+ {% render_field form.frontporttemplate %} +
+
+ {% render_field form.interfacetemplate %} +
+
+ {% render_field form.poweroutlettemplate %} +
+
+ {% render_field form.powerporttemplate %} +
+
+ {% render_field form.rearporttemplate %} +
+
+
+ + {% if form.custom_fields %} +
+
+
{% trans "Custom Fields" %}
+
+ {% render_custom_fields form %} +
+ {% endif %} +{% endblock %} From b6e38b2ebe0717b3a7606d36f5bc9b40444fb42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markku=20Leini=C3=B6?= Date: Mon, 22 Apr 2024 16:25:16 +0300 Subject: [PATCH 6/8] Closes #14690: Pretty-format JSON fields in the config form (#15623) * Closes #14690: Pretty-format JSON fields in the config form * Revert changes * Use our own JSONField for config parameters for pretty editor outputs * Compare identity instead of equality --- netbox/core/forms/model_forms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index ae891dd59..0f4f971dc 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -3,6 +3,7 @@ import json from django import forms from django.conf import settings +from django.forms.fields import JSONField as _JSONField from django.utils.translation import gettext_lazy as _ from core.forms.mixins import SyncedDataMixin @@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm from netbox.registry import registry from netbox.utils import get_data_backend_choices from utilities.forms import BootstrapMixin, get_field_value -from utilities.forms.fields import CommentField +from utilities.forms.fields import CommentField, JSONField from utilities.forms.widgets import HTMXSelect __all__ = ( @@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass): 'help_text': param.description, } field_kwargs.update(**param.field_kwargs) + if param.field is _JSONField: + # Replace with our own JSONField to get pretty JSON in config editor + param.field = JSONField param_fields[param.name] = param.field(**field_kwargs) attrs.update(param_fields) From ebe504c8252ca29cbdbc0a0bbf2ee0e0167fffcd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Apr 2024 09:52:03 -0400 Subject: [PATCH 7/8] Closes #15664: Restore usage of READTHEDOCS env variable --- docs/_theme/main.html | 4 ++-- mkdocs.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 3ff44b9cb..4dfc4e14e 100644 --- a/docs/_theme/main.html +++ b/docs/_theme/main.html @@ -2,8 +2,8 @@ {% block site_meta %} {{ super() }} - {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} - {% if page.canonical_url != 'https://docs.netbox.dev/' %} + {# Disable search indexing unless we're building for ReadTheDocs #} + {% if not config.extra.readthedocs %} {% endif %} {% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index c04ef519f..5aa657230 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ plugins: show_root_toc_entry: false show_source: false extra: + readthedocs: !ENV READTHEDOCS social: - icon: fontawesome/brands/github link: https://github.com/netbox-community/netbox From e87877b6ea15231df1e7e10a74c69a4e1c6407aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Apr 2024 09:38:40 -0400 Subject: [PATCH 8/8] Fixes #15771: Show id field as supported on all bulk import forms --- netbox/netbox/forms/base.py | 5 ----- netbox/utilities/forms/forms.py | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 0b0e2036e..736a2fe9b 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -73,11 +73,6 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ - id = forms.IntegerField( - label=_('Id'), - required=False, - help_text='Numeric ID of an existing object to update (if not creating a new object)' - ) tags = CSVModelMultipleChoiceField( label=_('Tags'), queryset=Tag.objects.all(), diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 54c9e41cb..93227b1d0 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -70,6 +70,12 @@ class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. """ + id = forms.IntegerField( + label=_('ID'), + required=False, + help_text=_('Numeric ID of an existing object to update (if not creating a new object)') + ) + def __init__(self, *args, headers=None, **kwargs): self.headers = headers or {} super().__init__(*args, **kwargs)