15803 merge develop

This commit is contained in:
Arthur 2024-04-22 08:13:55 -07:00
commit dd22e8e2ec
15 changed files with 257 additions and 30 deletions

View File

@ -2,8 +2,8 @@
{% block site_meta %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} {# Disable search indexing unless we're building for ReadTheDocs #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %} {% if not config.extra.readthedocs %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -16,10 +16,7 @@ BASE_PATH = 'netbox/'
Default: `en-us` (US English) 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.) 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.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
--- ---

View File

@ -42,6 +42,7 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox

View File

@ -3,6 +3,7 @@ import json
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms.fields import JSONField as _JSONField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm
from netbox.registry import registry from netbox.registry import registry
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BootstrapMixin, get_field_value 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 from utilities.forms.widgets import HTMXSelect
__all__ = ( __all__ = (
@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
'help_text': param.description, 'help_text': param.description,
} }
field_kwargs.update(**param.field_kwargs) 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) param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields) attrs.update(param_fields)

View File

@ -612,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
required=False, required=False,
allow_null=True 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) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
@ -685,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer):
) )
status = ChoiceField(choices=DeviceStatusChoices, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, 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_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
@ -735,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer):
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
@ -1067,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
required=False, required=False,
allow_null=True 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) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:

View File

@ -976,21 +976,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
component_type = ContentTypeChoiceField( # Assigned component selectors
queryset=ContentType.objects.all(), consoleporttemplate = DynamicModelChoiceField(
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, queryset=ConsolePortTemplate.objects.all(),
required=False, 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, 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 = ( fieldsets = (
(None, ( (None, (
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
)), )),
) )
@ -998,9 +1044,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
model = InventoryItemTemplate model = InventoryItemTemplate
fields = [ fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', '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 # Device components

View File

@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateCreateForm form = forms.InventoryItemTemplateCreateForm
model_form = forms.InventoryItemTemplateForm model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
def alter_object(self, instance, request): def alter_object(self, instance, request):
# Set component (if any) # Set component (if any)
@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
class InventoryItemTemplateEditView(generic.ObjectEditView): class InventoryItemTemplateEditView(generic.ObjectEditView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateForm form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
@register_model_view(InventoryItemTemplate, 'delete') @register_model_view(InventoryItemTemplate, 'delete')

View File

@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm):
required=False, required=False,
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.') help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
) )
comments = CommentField()
fieldsets = ( fieldsets = (
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')), (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),

View File

@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer):
Representation of a VLAN which does not exist in the database. Representation of a VLAN which does not exist in the database.
""" """
vid = serializers.IntegerField(read_only=True) vid = serializers.IntegerField(read_only=True)
group = NestedVLANGroupSerializer(read_only=True) group = NestedVLANGroupSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
return { return {
@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
""" """
Representation of a prefix which does not exist in the database. 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) prefix = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
if self.context.get('vrf'): 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. 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) address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
description = serializers.CharField(required=False) description = serializers.CharField(required=False)
def to_representation(self, instance): def to_representation(self, instance):

View File

@ -73,11 +73,6 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
""" """
Base form for creating a NetBox objects from CSV data. Used for bulk importing. 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( tags = CSVModelMultipleChoiceField(
label=_('Tags'), label=_('Tags'),
queryset=Tag.objects.all(), queryset=Tag.objects.all(),

View File

@ -0,0 +1,104 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Inventory Item" %}</h5>
</div>
{% 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 %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Hardware" %}</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.part_id %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Component Assignment" %}</h5>
</div>
<div class="row mb-2 offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}">
{% trans "Console Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverporttemplate %}active{% endif %}">
{% trans "Console Server Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontporttemplate %}active{% endif %}">
{% trans "Front Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interfacetemplate %}active{% endif %}">
{% trans "Interface" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlettemplate %}active{% endif %}">
{% trans "Power Outlet" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerporttemplate %}active{% endif %}">
{% trans "Power Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearporttemplate %}active{% endif %}">
{% trans "Rear Port" %}
</button>
</li>
</ul>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
{% render_field form.consoleporttemplate %}
</div>
<div class="tab-pane {% if form.initial.consoleserverporttemplate %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
{% render_field form.consoleserverporttemplate %}
</div>
<div class="tab-pane {% if form.initial.frontporttemplate %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
{% render_field form.frontporttemplate %}
</div>
<div class="tab-pane {% if form.initial.interfacetemplate %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.interfacetemplate %}
</div>
<div class="tab-pane {% if form.initial.poweroutlettemplate %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
{% render_field form.poweroutlettemplate %}
</div>
<div class="tab-pane {% if form.initial.powerporttemplate %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
{% render_field form.powerporttemplate %}
</div>
<div class="tab-pane {% if form.initial.rearporttemplate %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
{% render_field form.rearporttemplate %}
</div>
</div>
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@ -70,6 +70,12 @@ class CSVModelForm(forms.ModelForm):
""" """
ModelForm used for the import of objects in CSV format. 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): def __init__(self, *args, headers=None, **kwargs):
self.headers = headers or {} self.headers = headers or {}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -76,7 +76,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
role = NestedDeviceRoleSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(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_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)

View File

@ -136,6 +136,17 @@ class IKEProposalFilterSet(NetBoxModelFilterSet):
group = django_filters.MultipleChoiceFilter( group = django_filters.MultipleChoiceFilter(
choices=DHGroupChoices 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: class Meta:
model = IKEProposal model = IKEProposal

View File

@ -331,6 +331,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
IKEProposal.objects.bulk_create(ike_proposals) 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): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@ -369,6 +379,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'sa_lifetime': [1000, 2000]} params = {'sa_lifetime': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IKEPolicy.objects.all() queryset = IKEPolicy.objects.all()