Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch
2024-04-22 13:23:42 -04:00
67 changed files with 3840 additions and 3326 deletions

View File

@@ -64,6 +64,12 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
queryset=ASN.objects.all(),
label=_('ASN (ID)'),
)
asn = django_filters.ModelMultipleChoiceFilter(
field_name='asns__asn',
queryset=ASN.objects.all(),
to_field_name='asn',
label=_('ASN'),
)
class Meta:
model = Provider

View File

@@ -25,7 +25,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn', name=_('ASN')),
FieldSet('asn_id', name=_('ASN')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -47,10 +47,6 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
asn = forms.IntegerField(
required=False,
label=_('ASN (legacy)')
)
asn_id = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
required=False,

View File

@@ -90,10 +90,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn_id(self): # ASN object assignment
def test_asn(self):
asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'asn': [asns[0].asn, asns[1].asn]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]

View File

@@ -1,5 +1,5 @@
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -30,10 +30,11 @@ class DataSourceViewSet(NetBoxModelViewSet):
"""
Enqueue a job to synchronize the DataSource.
"""
if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk)
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
datasource.enqueue_sync_job(request)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})

View File

@@ -149,7 +149,8 @@ class S3Backend(DataBackend):
region_name=self._region_name,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
config=self.config
config=self.config,
endpoint_url=self._endpoint_url
)
bucket = s3.Bucket(self._bucket_name)
@@ -176,6 +177,11 @@ class S3Backend(DataBackend):
url_path = urlparse(self.url).path.lstrip('/')
return url_path.split('/')[0]
@property
def _endpoint_url(self):
url_path = urlparse(self.url)
return url_path._replace(params="", fragment="", query="", path="").geturl()
@property
def _remote_path(self):
url_path = urlparse(self.url).path.lstrip('/')

View File

@@ -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 get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.fields import CommentField, JSONField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect
@@ -133,6 +134,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)

View File

@@ -347,7 +347,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:

View File

@@ -53,7 +53,7 @@ class DeviceSerializer(NetBoxModelSerializer):
)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = IPAddressSerializer(nested=True, read_only=True)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
@@ -101,7 +101,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 = [

View File

@@ -307,7 +307,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:

View File

@@ -1373,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text='Assigned role'
help_text=_('Assigned role')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
help_text=_('Assigned tenant')
)
status = CSVChoiceField(
label=_('Status'),

View File

@@ -975,9 +975,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Color'),
required=False
)
length = forms.IntegerField(
length = forms.DecimalField(
label=_('Length'),
required=False
required=False,
)
length_unit = forms.ChoiceField(
label=_('Length unit'),

View File

@@ -13,8 +13,7 @@ from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
NumericArrayField, SlugField,
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
@@ -1003,15 +1002,62 @@ 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 = (
@@ -1025,9 +1071,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

View File

@@ -981,17 +981,16 @@ class Device(
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
model = queryset.model.component_model
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
@@ -1004,7 +1003,11 @@ class Device(
update_fields=None
)
else:
for component in components:
for obj in queryset:
component = obj.instantiate(device=self)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
component.custom_field_data = cf_defaults
component.save()
def save(self, *args, **kwargs):

View File

@@ -1655,6 +1655,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)
@@ -1672,6 +1673,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')

View File

@@ -116,6 +116,12 @@ class CustomLinkImportForm(CSVModelForm):
queryset=ObjectType.objects.with_feature('custom_links'),
help_text=_("One or more assigned object types")
)
button_class = CSVChoiceField(
label=_('button class'),
required=False,
choices=CustomLinkButtonClassChoices,
help_text=_('The class of the first link in a group will be used for the dropdown button')
)
class Meta:
model = CustomLink

View File

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

View File

@@ -100,7 +100,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
"""
family = serializers.IntegerField(read_only=True)
prefix = serializers.CharField(read_only=True)
vrf = VRFSerializer(nested=True, read_only=True)
vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
def to_representation(self, instance):
if self.context.get('vrf'):
@@ -183,7 +183,7 @@ class AvailableIPSerializer(serializers.Serializer):
"""
family = serializers.IntegerField(read_only=True)
address = serializers.CharField(read_only=True)
vrf = VRFSerializer(nested=True, read_only=True)
vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
description = serializers.CharField(required=False)
def to_representation(self, instance):

View File

@@ -82,7 +82,7 @@ class AvailableVLANSerializer(serializers.Serializer):
Representation of a VLAN which does not exist in the database.
"""
vid = serializers.IntegerField(read_only=True)
group = VLANGroupSerializer(nested=True, read_only=True)
group = VLANGroupSerializer(nested=True, read_only=True, allow_null=True)
def to_representation(self, instance):
return {

View File

@@ -533,6 +533,24 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
for ipaddress in ipaddresses:
self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
def clean_group(self):
group = self.cleaned_data['group']
conflicting_assignments = FHRPGroupAssignment.objects.filter(
interface_type=self.instance.interface_type,
interface_id=self.instance.interface_id,
group=group
)
if self.instance.id:
conflicting_assignments = conflicting_assignments.exclude(id=self.instance.id)
if conflicting_assignments.exists():
raise forms.ValidationError(
_('Assignment already exists')
)
return group
class VLANGroupForm(NetBoxModelForm):
scope_type = ContentTypeChoiceField(

View File

@@ -74,17 +74,12 @@ 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(),
required=False,
to_field_name='slug',
help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")'
help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")')
)
def _get_custom_fields(self, content_type):

View File

@@ -80,9 +80,10 @@ class SearchIndex:
@staticmethod
def get_field_value(instance, field_name):
"""
Return the value of the specified model field as a string.
Return the value of the specified model field as a string (or None).
"""
return str(getattr(instance, field_name))
if value := getattr(instance, field_name):
return str(value)
@classmethod
def get_category(cls):

View File

@@ -139,6 +139,9 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Required
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
SECURE_HSTS_PRELOAD = getattr(configuration, 'SECURE_HSTS_PRELOAD', False)
SECURE_HSTS_SECONDS = getattr(configuration, 'SECURE_HSTS_SECONDS', 0)
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)

View File

@@ -42,7 +42,7 @@
{% if perms.dcim.add_rearport %}
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_devicebay %}
{% if perms.dcim.add_modulebay %}
<li><a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
{% endif %}
{% if perms.dcim.add_devicebay %}

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 %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@ from utilities.forms.widgets import DateTimePicker
__all__ = (
'GroupFilterForm',
'ObjectPermissionFilterForm',
'UserFilterForm',
'TokenFilterForm',
'UserFilterForm',
)

View File

@@ -69,6 +69,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)

View File

@@ -14,7 +14,7 @@ from core.models import ObjectType
from users.models import ObjectPermission
from utilities.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type
from .utils import extract_form_failures
from .utils import DUMMY_CF_DATA, extract_form_failures
__all__ = (
'ModelTestCase',
@@ -169,8 +169,12 @@ class ModelTestCase(TestCase):
model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = {
model_data = {
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
}
self.assertDictEqual(model_dict, relevant_data)
self.assertDictEqual(model_dict, model_data)
# Validate any custom field data, if present
if getattr(instance, 'custom_field_data', None):
self.assertDictEqual(instance.custom_field_data, DUMMY_CF_DATA)

View File

@@ -1,3 +1,4 @@
import json
import logging
import re
from contextlib import contextmanager
@@ -6,8 +7,10 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.utils.text import slugify
from core.models import ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.models import Tag
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, Tag
from virtualization.models import Cluster, ClusterType, VirtualMachine
@@ -102,3 +105,42 @@ def disable_warnings(logger_name):
logger.setLevel(logging.ERROR)
yield
logger.setLevel(current_level)
#
# Custom field testing
#
DUMMY_CF_DATA = {
'text_field': 'foo123',
'integer_field': 456,
'decimal_field': 456.12,
'boolean_field': True,
'json_field': {'abc': 123},
}
def add_custom_field_data(form_data, model):
"""
Create some custom fields for the model and add a value for each to the form data.
Args:
form_data: The dictionary of form data to be updated
model: The model of the object the form seeks to create or modify
"""
object_type = ObjectType.objects.get_for_model(model)
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
)
CustomField.objects.bulk_create(custom_fields)
for cf in custom_fields:
cf.object_types.set([object_type])
form_data.update({
f'cf_{k}': v if type(v) is str else json.dumps(v)
for k, v in DUMMY_CF_DATA.items()
})

View File

@@ -12,10 +12,10 @@ from core.models import ObjectType
from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.models.features import ChangeLoggingMixin
from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
from users.models import ObjectPermission
from .base import ModelTestCase
from .utils import disable_warnings, post_data
from .utils import add_custom_field_data, disable_warnings, post_data
__all__ = (
'ModelViewTestCase',
@@ -27,7 +27,6 @@ __all__ = (
# UI Tests
#
class ModelViewTestCase(ModelTestCase):
"""
Base TestCase for model views. Subclass to test individual views.
@@ -167,6 +166,10 @@ class ViewTestCases:
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission
initial_count = self._get_queryset().count()
request = {
@@ -266,6 +269,10 @@ class ViewTestCases:
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission
request = {
'path': self._get_url('edit', instance),

View File

@@ -37,7 +37,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
platform = PlatformSerializer(nested=True, required=False, allow_null=True)
primary_ip = IPAddressSerializer(nested=True, read_only=True)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)

View File

@@ -388,7 +388,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
tab = ViewTab(
label=_('Virtual Disks'),
badge=lambda obj: obj.virtual_disk_count,
permission='virtualization.view_virtual_disk',
permission='virtualization.view_virtualdisk',
weight=500
)
actions = {

View File

@@ -108,6 +108,8 @@ class TunnelTerminationSerializer(NetBoxModelSerializer):
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if not obj.termination:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data

View File

@@ -147,6 +147,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

View File

@@ -92,7 +92,7 @@ class TunnelCreateForm(TunnelForm):
termination1_termination = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label=_('Interface'),
label=_('Tunnel interface'),
query_params={
'device_id': '$termination1_parent',
}
@@ -127,7 +127,7 @@ class TunnelCreateForm(TunnelForm):
termination2_termination = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label=_('Interface'),
label=_('Tunnel interface'),
query_params={
'device_id': '$termination2_parent',
}
@@ -237,7 +237,7 @@ class TunnelTerminationForm(NetBoxModelForm):
)
termination = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label=_('Interface'),
label=_('Tunnel interface'),
query_params={
'device_id': '$parent',
}

View File

@@ -75,6 +75,7 @@ class L2VPNIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
('identifier', 200),
('description', 500),
('comments', 5000),
)

View File

@@ -88,7 +88,7 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Host')
)
termination = tables.Column(
verbose_name=_('Interface'),
verbose_name=_('Tunnel interface'),
linkify=True
)
ip_addresses = tables.ManyToManyColumn(

View File

@@ -385,6 +385,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()

View File

@@ -42,7 +42,7 @@ class WirelessLANImportForm(NetBoxModelImportForm):
status = CSVChoiceField(
label=_('Status'),
choices=WirelessLANStatusChoices,
help_text='Operational status'
help_text=_('Operational status')
)
vlan = CSVModelChoiceField(
label=_('VLAN'),