From 762814598200ac6d5300936c9c9e55ed6c35ac43 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 21 Jun 2024 13:36:14 -0700 Subject: [PATCH] 9627 bulk import / edit --- netbox/ipam/api/serializers_/vlans.py | 12 ++++++++++- netbox/ipam/forms/bulk_edit.py | 4 +++- netbox/ipam/forms/bulk_import.py | 6 ++++-- netbox/ipam/models/vlans.py | 4 ++-- netbox/utilities/data.py | 30 ++++++++++++++++++++++++++ netbox/utilities/forms/fields/array.py | 19 ++++++++-------- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index bba7afe3d..5410ccb6f 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -10,6 +10,7 @@ from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountF from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from utilities.api import get_serializer_for_model +from utilities.data import ranges_to_string, string_to_range_array from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from .roles import RoleSerializer @@ -21,6 +22,14 @@ __all__ = ( ) +class NumericRangeArraySerializer(serializers.BaseSerializer): + def to_internal_value(self, data): + return string_to_range_array(data) + + def to_representation(self, instance): + return ranges_to_string(data) + + class VLANGroupSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') scope_type = ContentTypeField( @@ -37,11 +46,12 @@ class VLANGroupSerializer(NetBoxModelSerializer): # Related object counts vlan_count = RelatedObjectCountField('vlans') + vlan_id_ranges = NumericRangeArraySerializer() class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vlan_id_ranges', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 837dc4ce7..745a54d6d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -12,6 +12,7 @@ from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, + NumericRangeArrayField, ) from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect @@ -471,10 +472,11 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): 'group_id': '$clustergroup', } ) + vlan_id_ranges = NumericRangeArrayField() model = VLANGroup fieldsets = ( - FieldSet('site', 'description'), + FieldSet('site', 'vlan_id_ranges', 'description'), FieldSet( 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') ), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 79cf8d55a..e2362c4ed 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -9,7 +9,8 @@ from ipam.models import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms.fields import ( - CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField, + NumericRangeArrayField, ) from virtualization.models import VirtualMachine, VMInterface @@ -411,10 +412,11 @@ class VLANGroupImportForm(NetBoxModelImportForm): required=False, label=_('Scope type (app & model)') ) + vlan_id_ranges = NumericRangeArrayField() class Meta: model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'description', 'tags') + fields = ('name', 'slug', 'scope_type', 'scope_id', 'vlan_id_ranges', 'description', 'tags') labels = { 'scope_id': 'Scope ID', } diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index fa389e904..7acbf8064 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -11,7 +11,7 @@ from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from netbox.models import OrganizationalModel, PrimaryModel -from utilities.data import check_ranges_overlap +from utilities.data import check_ranges_overlap, ranges_to_string from virtualization.models import VMInterface __all__ = ( @@ -129,7 +129,7 @@ class VLANGroup(OrganizationalModel): @property def vlan_ranges(self): - return ','.join([f"{vlan_range.lower}-{vlan_range.upper}" for vlan_range in self.vlan_id_ranges]) + return ranges_to_string(self.vlan_id_ranges) class VLAN(PrimaryModel): diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index ea7b5b9df..d6f4a0275 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -8,7 +8,9 @@ __all__ = ( 'deepmerge', 'drange', 'flatten_dict', + 'ranges_to_string', 'shallow_compare_dict', + 'string_to_range_array', ) @@ -129,3 +131,31 @@ def check_ranges_overlap(ranges): return True return False + + +def ranges_to_string(ranges): + """ + Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. + For example: + [1-100, 200-300] => "1-100, 200-300" + """ + return ', '.join([f"{val.lower}-{val.upper}" for val in value]) + + +def string_to_range_array(value): + """ + Given a string in the format "1-100, 200-300" create an array of ranges. Intended for use with ArrayField. + For example: + "1-100, 200-300" => [1-100, 200-300] + """ + if not value: + return None + ranges = value.split(",") + values = [] + for dash_range in value.split(','): + if '-' not in dash_range: + return None + + lower, upper = dash_range.split('-') + values.append(NumericRange(int(lower), int(upper))) + return values diff --git a/netbox/utilities/forms/fields/array.py b/netbox/utilities/forms/fields/array.py index 84e6b1a2b..cb56dfb2c 100644 --- a/netbox/utilities/forms/fields/array.py +++ b/netbox/utilities/forms/fields/array.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.db.backends.postgresql.psycopg_any import NumericRange from django.utils.translation import gettext_lazy as _ +from utilities.data import ranges_to_string, string_to_range_array from ..utils import parse_numeric_range @@ -42,17 +43,17 @@ class NumericRangeArrayField(forms.CharField): "Example: 1-5,20-30" ) + def clean(self, value): + if value and not self.to_python(value): + raise forms.ValidationError( + _("Invalid ranges ({value}). Must be range of number '100-200' and ranges must be in ascending order.").format(value=value) + ) + return super().clean(value) + def prepare_value(self, value): if isinstance(value, str): return value - return ','.join([f"{val.lower}-{val.upper}" for val in value]) + return ranges_to_string(value) def to_python(self, value): - if not value: - return None - ranges = value.split(",") - values = [] - for dash_range in value.split(','): - lower, upper = dash_range.split('-') - values.append(NumericRange(int(lower), int(upper))) - return values + return string_to_range_array(value)