Merge pull request #4564 from netbox-community/3147-csv-import-fields

Closes #3147: Allow dynamic access to related objects during CSV import
This commit is contained in:
Jeremy Stretch 2020-05-06 10:15:00 -04:00 committed by GitHub
commit 9312dea2b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 706 additions and 879 deletions

View File

@ -8,9 +8,9 @@ from extras.forms import (
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
StaticSelect2Multiple, TagFilterField,
APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField,
CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField,
StaticSelect2, StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -55,12 +55,6 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Provider
fields = Provider.csv_headers
help_texts = {
'name': 'Provider name',
'asn': '32-bit autonomous system number',
'portal_url': 'Portal URL',
'comments': 'Free-form comments',
}
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@ -148,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
]
class CircuitTypeCSVForm(forms.ModelForm):
class CircuitTypeCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
@ -192,35 +186,26 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class CircuitCSVForm(CustomFieldModelCSVForm):
provider = forms.ModelChoiceField(
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Name of parent provider',
error_messages={
'invalid_choice': 'Provider not found.'
}
help_text='Assigned provider'
)
type = forms.ModelChoiceField(
type = CSVModelChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='name',
help_text='Type of circuit',
error_messages={
'invalid_choice': 'Invalid circuit type.'
}
help_text='Type of circuit'
)
status = CSVChoiceField(
choices=CircuitStatusChoices,
required=False,
help_text='Operational status'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.'
}
help_text='Assigned tenant'
)
class Meta:

View File

@ -38,7 +38,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN'
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
account = models.CharField(
max_length=30,
@ -47,7 +48,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
)
portal_url = models.URLField(
blank=True,
verbose_name='Portal'
verbose_name='Portal URL'
)
noc_contact = models.TextField(
blank=True,

File diff suppressed because it is too large Load Diff

View File

@ -180,12 +180,14 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
facility = models.CharField(
max_length=50,
blank=True
blank=True,
help_text='Local facility ID or description'
)
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN'
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
time_zone = TimeZoneField(
blank=True
@ -206,13 +208,15 @@ class Site(ChangeLoggedModel, CustomFieldModel):
max_digits=8,
decimal_places=6,
blank=True,
null=True
null=True,
help_text='GPS coordinate (latitude)'
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True
null=True,
help_text='GPS coordinate (longitude)'
)
contact_name = models.CharField(
max_length=50,
@ -419,7 +423,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
max_length=50,
blank=True,
null=True,
verbose_name='Facility ID'
verbose_name='Facility ID',
help_text='Locally-assigned identifier'
)
site = models.ForeignKey(
to='dcim.Site',
@ -431,7 +436,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
on_delete=models.SET_NULL,
related_name='racks',
blank=True,
null=True
null=True,
help_text='Assigned group'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@ -450,7 +456,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True
null=True,
help_text='Functional role'
)
serial = models.CharField(
max_length=50,
@ -480,7 +487,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]
validators=[MinValueValidator(1), MaxValueValidator(100)],
help_text='Height in rack units'
)
desc_units = models.BooleanField(
default=False,
@ -489,11 +497,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
outer_width = models.PositiveSmallIntegerField(
blank=True,
null=True
null=True,
help_text='Outer dimension of rack (width)'
)
outer_depth = models.PositiveSmallIntegerField(
blank=True,
null=True
null=True,
help_text='Outer dimension of rack (depth)'
)
outer_unit = models.CharField(
max_length=50,
@ -514,7 +524,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
]
clone_fields = [
@ -821,7 +831,7 @@ class RackReservation(ChangeLoggedModel):
def clean(self):
if self.units:
if hasattr(self, 'rack') and self.units:
# Validate that all specified units exist in the Rack.
invalid_units = [u for u in self.units if u not in self.rack.units]
@ -1415,7 +1425,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
]
clone_fields = [
@ -1798,7 +1808,7 @@ class PowerPanel(ChangeLoggedModel):
max_length=50
)
csv_headers = ['site', 'rack_group_name', 'name']
csv_headers = ['site', 'rack_group', 'name']
class Meta:
ordering = ['site', 'name']
@ -1905,7 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments',
]
clone_fields = [

View File

@ -239,7 +239,8 @@ class ConsolePort(CableTermination, ComponentModel):
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
blank=True,
help_text='Physical port type'
)
connected_endpoint = models.OneToOneField(
to='dcim.ConsoleServerPort',
@ -300,7 +301,8 @@ class ConsoleServerPort(CableTermination, ComponentModel):
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
blank=True,
help_text='Physical port type'
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
@ -354,7 +356,8 @@ class PowerPort(CableTermination, ComponentModel):
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
blank=True
blank=True,
help_text='Physical port type'
)
maximum_draw = models.PositiveSmallIntegerField(
blank=True,
@ -516,7 +519,8 @@ class PowerOutlet(CableTermination, ComponentModel):
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
blank=True
blank=True,
help_text='Physical port type'
)
power_port = models.ForeignKey(
to='dcim.PowerPort',
@ -653,7 +657,7 @@ class Interface(CableTermination, ComponentModel):
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True,
blank=True
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
@ -1083,7 +1087,8 @@ class InventoryItem(ComponentModel):
part_id = models.CharField(
max_length=50,
verbose_name='Part ID',
blank=True
blank=True,
help_text='Manufacturer-assigned part identifier'
)
serial = models.CharField(
max_length=50,
@ -1100,7 +1105,7 @@ class InventoryItem(ComponentModel):
)
discovered = models.BooleanField(
default=False,
verbose_name='Discovered'
help_text='This item was automatically discovered'
)
tags = TaggableManager(through=TaggedItem)

View File

@ -184,7 +184,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site = Site.objects.create(name='Site 1', slug='site-1')
rack = Rack(name='Rack 1', site=site)
rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site)
rack_group.save()
rack = Rack(name='Rack 1', site=site, group=rack_group)
rack.save()
RackReservation.objects.bulk_create([
@ -202,10 +205,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'site,rack_name,units,description',
'Site 1,Rack 1,"10,11,12",Reservation 1',
'Site 1,Rack 1,"13,14,15",Reservation 2',
'Site 1,Rack 1,"16,17,18",Reservation 3',
'site,rack_group,rack,units,description',
'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1',
'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2',
'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3',
)
cls.bulk_edit_data = {
@ -268,10 +271,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,name,width,u_height",
"Site 1,Rack 4,19,42",
"Site 1,Rack 5,19,42",
"Site 1,Rack 6,19,42",
"site,group,name,width,u_height",
"Site 1,,Rack 4,19,42",
"Site 1,Rack Group 1,Rack 5,19,42",
"Site 2,Rack Group 2,Rack 6,19,42",
)
cls.bulk_edit_data = {
@ -890,8 +893,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Site.objects.bulk_create(sites)
rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
rack_group.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 1', site=sites[0], group=rack_group),
Rack(name='Rack 2', site=sites[1]),
)
Rack.objects.bulk_create(racks)
@ -947,10 +953,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"device_role,manufacturer,model_name,status,site,name",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 4",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 5",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6",
"device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front",
)
cls.bulk_edit_data = {
@ -1586,7 +1592,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,rack_group_name,name",
"site,rack_group,name",
"Site 1,Rack Group 1,Power Panel 4",
"Site 1,Rack Group 1,Power Panel 5",
"Site 1,Rack Group 1,Power Panel 6",
@ -1645,7 +1651,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,panel_name,name,voltage,amperage,max_utilization",
"site,power_panel,name,voltage,amperage,max_utilization",
"Site 1,Power Panel 1,Power Feed 4,120,20,80",
"Site 1,Power Panel 1,Power Feed 5,120,20,80",
"Site 1,Power Panel 1,Power Feed 6,120,20,80",

View File

@ -8,7 +8,7 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
@ -89,7 +89,7 @@ class CustomFieldModelForm(forms.ModelForm):
return obj
class CustomFieldModelCSVForm(CustomFieldModelForm):
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _append_customfield_fields(self):

View File

@ -1,5 +1,4 @@
from django import forms
from django.core.exceptions import MultipleObjectsReturned
from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
@ -11,16 +10,15 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
CSVModelChoiceField, CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from .constants import *
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
])
@ -53,22 +51,16 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class VRFCSVForm(CustomFieldModelCSVForm):
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
help_text='Assigned tenant'
)
class Meta:
model = VRF
fields = VRF.csv_headers
help_texts = {
'name': 'VRF name',
}
class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@ -120,7 +112,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
]
class RIRCSVForm(forms.ModelForm):
class RIRCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
@ -168,13 +160,10 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
class AggregateCSVForm(CustomFieldModelCSVForm):
rir = forms.ModelChoiceField(
rir = CSVModelChoiceField(
queryset=RIR.objects.all(),
to_field_name='name',
help_text='Name of parent RIR',
error_messages={
'invalid_choice': 'RIR not found.',
}
help_text='Assigned RIR'
)
class Meta:
@ -247,15 +236,12 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
]
class RoleCSVForm(forms.ModelForm):
class RoleCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Role
fields = Role.csv_headers
help_texts = {
'name': 'Role name',
}
#
@ -333,92 +319,62 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class PrefixCSVForm(CustomFieldModelCSVForm):
vrf = FlexibleModelChoiceField(
vrf = CSVModelChoiceField(
queryset=VRF.objects.all(),
to_field_name='name',
required=False,
help_text='Name of parent VRF (or {ID})',
error_messages={
'invalid_choice': 'VRF not found.',
}
help_text='Assigned VRF'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
help_text='Assigned tenant'
)
site = forms.ModelChoiceField(
site = CSVModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
help_text='Assigned site'
)
vlan_group = forms.CharField(
help_text='Group name of assigned VLAN',
required=False
vlan_group = CSVModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
to_field_name='name',
help_text="VLAN's group (if any)"
)
vlan_vid = forms.IntegerField(
help_text='Numeric ID of assigned VLAN',
required=False
vlan = CSVModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
to_field_name='vid',
help_text="Assigned VLAN"
)
status = CSVChoiceField(
choices=PrefixStatusChoices,
help_text='Operational status'
)
role = forms.ModelChoiceField(
role = CSVModelChoiceField(
queryset=Role.objects.all(),
required=False,
to_field_name='name',
help_text='Functional role',
error_messages={
'invalid_choice': 'Invalid role.',
}
help_text='Functional role'
)
class Meta:
model = Prefix
fields = Prefix.csv_headers
def clean(self):
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
super().clean()
if data:
site = self.cleaned_data.get('site')
vlan_group = self.cleaned_data.get('vlan_group')
vlan_vid = self.cleaned_data.get('vlan_vid')
# Validate VLAN
if vlan_group and vlan_vid:
try:
self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid)
except VLAN.DoesNotExist:
if site:
raise forms.ValidationError("VLAN {} not found in site {} group {}".format(
vlan_vid, site, vlan_group
))
else:
raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
except MultipleObjectsReturned:
raise forms.ValidationError(
"Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group)
)
elif vlan_vid:
try:
self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
except VLAN.DoesNotExist:
if site:
raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
else:
raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
except MultipleObjectsReturned:
raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
# Limit vlan queryset by assigned site and group
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'),
}
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@ -737,23 +693,17 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class IPAddressCSVForm(CustomFieldModelCSVForm):
vrf = FlexibleModelChoiceField(
vrf = CSVModelChoiceField(
queryset=VRF.objects.all(),
to_field_name='name',
required=False,
help_text='Name of parent VRF (or {ID})',
error_messages={
'invalid_choice': 'VRF not found.',
}
help_text='Assigned VRF'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of the assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
help_text='Assigned tenant'
)
status = CSVChoiceField(
choices=IPAddressStatusChoices,
@ -764,27 +714,23 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='Functional role'
)
device = FlexibleModelChoiceField(
device = CSVModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text='Name or ID of assigned device',
error_messages={
'invalid_choice': 'Device not found.',
}
help_text='Parent device of assigned interface (if any)'
)
virtual_machine = forms.ModelChoiceField(
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned virtual machine',
error_messages={
'invalid_choice': 'Virtual machine not found.',
}
help_text='Parent VM of assigned interface (if any)'
)
interface_name = forms.CharField(
help_text='Name of assigned interface',
required=False
interface = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned interface'
)
is_primary = forms.BooleanField(
help_text='Make this the primary IP for the assigned device',
@ -795,38 +741,34 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
model = IPAddress
fields = IPAddress.csv_headers
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit interface queryset by assigned device or virtual machine
if data.get('device'):
params = {
f"device__{self.fields['device'].to_field_name}": data.get('device')
}
elif data.get('virtual_machine'):
params = {
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
}
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
def clean(self):
super().clean()
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary')
# Validate interface
if interface_name and device:
try:
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface {} for device {}".format(
interface_name, device
))
elif interface_name and virtual_machine:
try:
self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name)
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface {} for virtual machine {}".format(
interface_name, virtual_machine
))
elif interface_name:
raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format(
interface_name
))
elif device:
raise forms.ValidationError("Device specified ({}) but interface missing".format(device))
elif virtual_machine:
raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine))
# Validate is_primary
if is_primary and not device and not virtual_machine:
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
@ -993,24 +935,18 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
]
class VLANGroupCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
class VLANGroupCSVForm(CSVModelForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
help_text='Assigned site'
)
slug = SlugField()
class Meta:
model = VLANGroup
fields = VLANGroup.csv_headers
help_texts = {
'name': 'Name of VLAN group',
}
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
@ -1082,40 +1018,33 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class VLANCSVForm(CustomFieldModelCSVForm):
site = forms.ModelChoiceField(
site = CSVModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
help_text='Assigned site'
)
group_name = forms.CharField(
help_text='Name of VLAN group',
required=False
group = CSVModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned VLAN group'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.',
}
help_text='Assigned tenant'
)
status = CSVChoiceField(
choices=VLANStatusChoices,
help_text='Operational status'
)
role = forms.ModelChoiceField(
role = CSVModelChoiceField(
queryset=Role.objects.all(),
required=False,
to_field_name='name',
help_text='Functional role',
error_messages={
'invalid_choice': 'Invalid role.',
}
help_text='Functional role'
)
class Meta:
@ -1126,25 +1055,14 @@ class VLANCSVForm(CustomFieldModelCSVForm):
'name': 'VLAN name',
}
def clean(self):
super().clean()
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
site = self.cleaned_data.get('site')
group_name = self.cleaned_data.get('group_name')
if data:
# Validate VLAN group
if group_name:
try:
self.instance.group = VLANGroup.objects.get(site=site, name=group_name)
except VLANGroup.DoesNotExist:
if site:
raise forms.ValidationError(
"VLAN group {} not found for site {}".format(group_name, site)
)
else:
raise forms.ValidationError(
"Global VLAN group {} not found".format(group_name)
)
# Limit vlan queryset by assigned group
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['group'].queryset = self.fields['group'].queryset.filter(**params)
class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@ -1299,23 +1217,17 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
class ServiceCSVForm(CustomFieldModelCSVForm):
device = FlexibleModelChoiceField(
device = CSVModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text='Name or ID of device',
error_messages={
'invalid_choice': 'Device not found.',
}
help_text='Required if not assigned to a VM'
)
virtual_machine = FlexibleModelChoiceField(
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name',
help_text='Name or ID of virtual machine',
error_messages={
'invalid_choice': 'Virtual machine not found.',
}
help_text='Required if not assigned to a device'
)
protocol = CSVChoiceField(
choices=ServiceProtocolChoices,
@ -1325,8 +1237,6 @@ class ServiceCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Service
fields = Service.csv_headers
help_texts = {
}
class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

View File

@ -50,7 +50,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
unique=True,
blank=True,
null=True,
verbose_name='Route distinguisher'
verbose_name='Route distinguisher',
help_text='Unique route distinguisher (as defined in RFC 4364)'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@ -364,7 +365,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
]
clone_fields = [
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
@ -635,7 +636,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'dns_name', 'description',
]
clone_fields = [
@ -925,7 +926,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description',
]
@ -1017,7 +1018,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
choices=ServiceProtocolChoices
)
port = models.PositiveIntegerField(
validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
validators=[
MinValueValidator(SERVICE_PORT_MIN),
MaxValueValidator(SERVICE_PORT_MAX)
],
verbose_name='Port number'
)
ipaddresses = models.ManyToManyField(

View File

@ -8,8 +8,8 @@ from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
)
from .constants import *
from .models import Secret, SecretRole, UserKey
@ -55,15 +55,12 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
}
class SecretRoleCSVForm(forms.ModelForm):
class SecretRoleCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = SecretRole
fields = SecretRole.csv_headers
help_texts = {
'name': 'Name of secret role',
}
#
@ -120,21 +117,15 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
class SecretCSVForm(CustomFieldModelCSVForm):
device = FlexibleModelChoiceField(
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Device name or ID',
error_messages={
'invalid_choice': 'Device not found.',
}
help_text='Assigned device'
)
role = forms.ModelChoiceField(
role = CSVModelChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='name',
help_text='Name of assigned role',
error_messages={
'invalid_choice': 'Invalid secret role.',
}
help_text='Assigned role'
)
plaintext = forms.CharField(
help_text='Plaintext secret data'

View File

@ -3,58 +3,95 @@
{% load form_helpers %}
{% block content %}
<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
{% block tabs %}{% endblock %}
<div class="row">
<div class="col-md-7">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
<div class="row">
<div class="col-md-8 col-md-offset-2">
<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
</div>
{% endif %}
<form action="" method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endif %}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#csv" role="tab" data-toggle="tab">CSV</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="csv">
<form action="" method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<div class="clearfix"></div>
<p></p>
{% if fields %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>CSV Field Options</strong>
</div>
<table class="table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Accessor</th>
<th>Description</th>
</tr>
{% for name, field in fields.items %}
<tr>
<td>
<code>{{ name }}</code>
</td>
<td>
{% if field.required %}
<i class="fa fa-check text-success" title="Required"></i>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if field.to_field_name %}
<code>{{ field.to_field_name }}</code>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if field.help_text %}
{{ field.help_text }}<br />
{% elif field.label %}
{{ field.label }}<br />
{% endif %}
{% if field|widget_type == 'dateinput' %}
<small class="text-muted">Format: YYYY-MM-DD</small>
{% elif field|widget_type == 'checkboxinput' %}
<small class="text-muted">Specify "true" or "false"</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
<p class="small text-muted">
<i class="fa fa-check"></i> Required fields <strong>must</strong> be specified for all
objects.
</p>
<p class="small text-muted">
<i class="fa fa-info-circle"></i> Related objects may be referenced by any unique attribute.
For example, <code>vrf.rd</code> would identify a VRF by its route distinguisher.
</p>
{% endif %}
</div>
</div>
</form>
</div>
<div class="col-md-5">
{% if fields %}
<h4 class="text-center">CSV Format</h4>
<table class="table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
{% for name, field in fields.items %}
<tr>
<td><code>{{ name }}</code></td>
<td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
<td>
{{ field.help_text|default:field.label }}
{% if field.choices %}
<br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
{% elif field|widget_type == 'dateinput' %}
<br /><small class="text-muted">Format: YYYY-MM-DD</small>
{% elif field|widget_type == 'checkboxinput' %}
<br /><small class="text-muted">Specify "true" or "false"</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -2,11 +2,11 @@ from django import forms
from taggit.forms import TagField
from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
)
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SlugField, TagFilterField,
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
)
from .models import Tenant, TenantGroup
@ -32,24 +32,18 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm):
]
class TenantGroupCSVForm(forms.ModelForm):
parent = forms.ModelChoiceField(
class TenantGroupCSVForm(CSVModelForm):
parent = CSVModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent tenant group',
error_messages={
'invalid_choice': 'Tenant group not found.',
}
help_text='Parent group'
)
slug = SlugField()
class Meta:
model = TenantGroup
fields = TenantGroup.csv_headers
help_texts = {
'name': 'Group name',
}
#
@ -74,25 +68,18 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
)
class TenantCSVForm(CustomFieldModelForm):
class TenantCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
group = forms.ModelChoiceField(
group = CSVModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent group',
error_messages={
'invalid_choice': 'Group not found.'
}
help_text='Assigned group'
)
class Meta:
model = Tenant
fields = Tenant.csv_headers
help_texts = {
'name': 'Tenant name',
'comments': 'Free-form comments'
}
class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

View File

@ -8,6 +8,7 @@ import yaml
from django import forms
from django.conf import settings
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
from django.forms import BoundField
from django.forms.models import fields_for_model
@ -400,15 +401,22 @@ class TimePicker(forms.TextInput):
class CSVDataField(forms.CharField):
"""
A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
column headers to values. Each dictionary represents an individual record.
A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
(where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
:param from_form: The form from which the field derives its validation rules.
"""
widget = forms.Textarea
def __init__(self, fields, required_fields=[], *args, **kwargs):
def __init__(self, from_form, *args, **kwargs):
self.fields = fields
self.required_fields = required_fields
form = from_form()
self.model = form.Meta.model
self.fields = form.fields
self.required_fields = [
name for name, field in form.fields.items() if field.required
]
super().__init__(*args, **kwargs)
@ -416,7 +424,7 @@ class CSVDataField(forms.CharField):
if not self.label:
self.label = ''
if not self.initial:
self.initial = ','.join(required_fields) + '\n'
self.initial = ','.join(self.required_fields) + '\n'
if not self.help_text:
self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
@ -425,36 +433,55 @@ class CSVDataField(forms.CharField):
def to_python(self, value):
records = []
reader = csv.reader(StringIO(value))
reader = csv.reader(StringIO(value.strip()))
# Consume and validate the first line of CSV data as column headers
headers = next(reader)
# Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
# "to" field specifying how the related object is being referenced. For example, importing a Device might use a
# `site.slug` header, to indicate the related site is being referenced by its slug.
headers = {}
for header in next(reader):
if '.' in header:
field, to_field = header.split('.', 1)
headers[field] = to_field
else:
headers[header] = None
# Parse CSV rows into a list of dictionaries mapped from the column headers.
for i, row in enumerate(reader, start=1):
if len(row) != len(headers):
raise forms.ValidationError(
f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
)
row = [col.strip() for col in row]
record = dict(zip(headers.keys(), row))
records.append(record)
return headers, records
def validate(self, value):
headers, records = value
# Validate provided column headers
for field, to_field in headers.items():
if field not in self.fields:
raise forms.ValidationError(f'Unexpected column header "{field}" found.')
if to_field and not hasattr(self.fields[field], 'to_field_name'):
raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
if to_field and not hasattr(self.fields[field].queryset.model, to_field):
raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
# Validate required fields
for f in self.required_fields:
if f not in headers:
raise forms.ValidationError('Required column header "{}" not found.'.format(f))
for f in headers:
if f not in self.fields:
raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
raise forms.ValidationError(f'Required column header "{f}" not found.')
# Parse CSV data
for i, row in enumerate(reader, start=1):
if row:
if len(row) != len(headers):
raise forms.ValidationError(
"Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
)
row = [col.strip() for col in row]
record = dict(zip(headers, row))
records.append(record)
return records
return value
class CSVChoiceField(forms.ChoiceField):
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
def __init__(self, choices, *args, **kwargs):
super().__init__(choices=choices, *args, **kwargs)
self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
@ -469,6 +496,23 @@ class CSVChoiceField(forms.ChoiceField):
return self.choice_values[value]
class CSVModelChoiceField(forms.ModelChoiceField):
"""
Provides additional validation for model choices entered as CSV data.
"""
default_error_messages = {
'invalid_choice': 'Object not found.',
}
def to_python(self, value):
try:
return super().to_python(value)
except MultipleObjectsReturned as e:
raise forms.ValidationError(
f'"{value}" is not a unique value for this field; multiple objects were found'
)
class ExpandableNameField(forms.CharField):
"""
A field which allows for numeric range expansion
@ -530,27 +574,6 @@ class CommentField(forms.CharField):
super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
class FlexibleModelChoiceField(forms.ModelChoiceField):
"""
Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
"""
def to_python(self, value):
if value in self.empty_values:
return None
try:
if not self.to_field_name:
key = 'pk'
elif re.match(r'^\{\d+\}$', value):
key = 'pk'
value = value.strip('{}')
else:
key = self.to_field_name
value = self.queryset.get(**{key: value})
except (ValueError, TypeError, self.queryset.model.DoesNotExist):
raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
return value
class SlugField(forms.SlugField):
"""
Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
@ -709,6 +732,20 @@ class BulkEditForm(forms.Form):
self.nullable_fields = self.Meta.nullable_fields
class CSVModelForm(forms.ModelForm):
"""
ModelForm used for the import of objects in CSV format.
"""
def __init__(self, *args, headers=None, **kwargs):
super().__init__(*args, **kwargs)
# Modify the model form to accommodate any customized to_field_name properties
if headers:
for field, to_field in headers.items():
if to_field is not None:
self.fields[field].to_field_name = to_field
class ImportForm(BootstrapMixin, forms.Form):
"""
Generic form for creating an object from JSON/YAML data

View File

@ -116,28 +116,6 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
@register.filter()
def example_choices(field, arg=3):
"""
Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
"""
examples = []
if hasattr(field, 'queryset'):
choices = [
(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]
]
else:
choices = field.choices
for value, label in unpack_grouped_choices(choices):
if len(examples) == arg:
examples.append('etc.')
break
if not value or not label:
continue
examples.append(label)
return ', '.join(examples) or 'None'
@register.filter()
def tzoffset(value):
"""

View File

@ -1,6 +1,8 @@
from django import forms
from django.test import TestCase
from ipam.forms import IPAddressCSVForm
from ipam.models import VRF
from utilities.forms import *
@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase):
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a,,b]a'))
class CSVDataFieldTest(TestCase):
def setUp(self):
self.field = CSVDataField(from_form=IPAddressCSVForm)
def test_clean(self):
input = """
address,status,vrf
192.0.2.1/32,Active,Test VRF
"""
output = (
{'address': None, 'status': None, 'vrf': None},
[{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
)
self.assertEqual(self.field.clean(input), output)
def test_clean_invalid_header(self):
input = """
address,status,vrf,xxx
192.0.2.1/32,Active,Test VRF,123
"""
with self.assertRaises(forms.ValidationError):
self.field.clean(input)
def test_clean_missing_required_header(self):
input = """
status,vrf
Active,Test VRF
"""
with self.assertRaises(forms.ValidationError):
self.field.clean(input)
def test_clean_default_to_field(self):
input = """
address,status,vrf.name
192.0.2.1/32,Active,Test VRF
"""
output = (
{'address': None, 'status': None, 'vrf': 'name'},
[{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}]
)
self.assertEqual(self.field.clean(input), output)
def test_clean_pk_to_field(self):
input = """
address,status,vrf.pk
192.0.2.1/32,Active,123
"""
output = (
{'address': None, 'status': None, 'vrf': 'pk'},
[{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}]
)
self.assertEqual(self.field.clean(input), output)
def test_clean_custom_to_field(self):
input = """
address,status,vrf.rd
192.0.2.1/32,Active,123:456
"""
output = (
{'address': None, 'status': None, 'vrf': 'rd'},
[{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}]
)
self.assertEqual(self.field.clean(input), output)
def test_clean_invalid_to_field(self):
input = """
address,status,vrf.xxx
192.0.2.1/32,Active,123:456
"""
with self.assertRaises(forms.ValidationError):
self.field.clean(input)
def test_clean_to_field_on_non_object(self):
input = """
address,status.foo,vrf
192.0.2.1/32,Bar,Test VRF
"""
with self.assertRaises(forms.ValidationError):
self.field.clean(input)

View File

@ -575,11 +575,11 @@ class BulkImportView(GetReturnURLMixin, View):
def _import_form(self, *args, **kwargs):
fields = self.model_form().fields.keys()
required_fields = [name for name, field in self.model_form().fields.items() if field.required]
class ImportForm(BootstrapMixin, Form):
csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs))
csv = CSVDataField(
from_form=self.model_form,
widget=Textarea(attrs=self.widget_attrs)
)
return ImportForm(*args, **kwargs)
@ -609,8 +609,10 @@ class BulkImportView(GetReturnURLMixin, View):
try:
# Iterate through CSV data and bind each row to a new model form instance.
with transaction.atomic():
for row, data in enumerate(form.cleaned_data['csv'], start=1):
obj_form = self.model_form(data)
headers, records = form.cleaned_data['csv']
for row, data in enumerate(records, start=1):
obj_form = self.model_form(data, headers=headers)
if obj_form.is_valid():
obj = self._save_obj(obj_form, request)
new_objs.append(obj)

View File

@ -14,9 +14,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple,
TagFilterField,
CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
StaticSelect2, StaticSelect2Multiple, TagFilterField,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -36,15 +36,12 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
]
class ClusterTypeCSVForm(forms.ModelForm):
class ClusterTypeCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = ClusterType
fields = ClusterType.csv_headers
help_texts = {
'name': 'Name of cluster type',
}
#
@ -61,15 +58,12 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
]
class ClusterGroupCSVForm(forms.ModelForm):
class ClusterGroupCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = ClusterGroup
fields = ClusterGroup.csv_headers
help_texts = {
'name': 'Name of cluster group',
}
#
@ -101,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ClusterCSVForm(CustomFieldModelCSVForm):
type = forms.ModelChoiceField(
type = CSVModelChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='name',
help_text='Name of cluster type',
error_messages={
'invalid_choice': 'Invalid cluster type name.',
}
help_text='Type of cluster'
)
group = forms.ModelChoiceField(
group = CSVModelChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='name',
required=False,
help_text='Name of cluster group',
error_messages={
'invalid_choice': 'Invalid cluster group name.',
}
help_text='Assigned cluster group'
)
site = forms.ModelChoiceField(
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
required=False,
help_text='Name of assigned site',
error_messages={
'invalid_choice': 'Invalid site name.',
}
help_text='Assigned site'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Invalid tenant name'
}
help_text='Assigned tenant'
)
class Meta:
@ -407,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='Operational status of device'
)
cluster = forms.ModelChoiceField(
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
help_text='Name of parent cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
help_text='Assigned cluster'
)
role = forms.ModelChoiceField(
role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
),
required=False,
to_field_name='name',
help_text='Name of functional role',
error_messages={
'invalid_choice': 'Invalid role name.'
}
help_text='Functional role'
)
tenant = forms.ModelChoiceField(
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Tenant not found.'
}
help_text='Assigned tenant'
)
platform = forms.ModelChoiceField(
platform = CSVModelChoiceField(
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned platform',
error_messages={
'invalid_choice': 'Invalid platform.',
}
help_text='Assigned platform'
)
class Meta: