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

View File

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

View File

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

View File

@ -184,7 +184,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site = Site.objects.create(name='Site 1', slug='site-1') 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() rack.save()
RackReservation.objects.bulk_create([ RackReservation.objects.bulk_create([
@ -202,10 +205,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'site,rack_name,units,description', 'site,rack_group,rack,units,description',
'Site 1,Rack 1,"10,11,12",Reservation 1', 'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1',
'Site 1,Rack 1,"13,14,15",Reservation 2', 'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2',
'Site 1,Rack 1,"16,17,18",Reservation 3', 'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -268,10 +271,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"site,name,width,u_height", "site,group,name,width,u_height",
"Site 1,Rack 4,19,42", "Site 1,,Rack 4,19,42",
"Site 1,Rack 5,19,42", "Site 1,Rack Group 1,Rack 5,19,42",
"Site 1,Rack 6,19,42", "Site 2,Rack Group 2,Rack 6,19,42",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -890,8 +893,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
rack_group.save()
racks = ( 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(name='Rack 2', site=sites[1]),
) )
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
@ -947,10 +953,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"device_role,manufacturer,model_name,status,site,name", "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 4", "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,Site 1,Device 5", "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,Site 1,Device 6", "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1586,7 +1592,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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 4",
"Site 1,Rack Group 1,Power Panel 5", "Site 1,Rack Group 1,Power Panel 5",
"Site 1,Rack Group 1,Power Panel 6", "Site 1,Rack Group 1,Power Panel 6",
@ -1645,7 +1651,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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 4,120,20,80",
"Site 1,Power Panel 1,Power Feed 5,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", "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 tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, 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, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
@ -89,7 +89,7 @@ class CustomFieldModelForm(forms.ModelForm):
return obj return obj
class CustomFieldModelCSVForm(CustomFieldModelForm): class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _append_customfield_fields(self): def _append_customfield_fields(self):

View File

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

View File

@ -50,7 +50,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
unique=True, unique=True,
blank=True, blank=True,
null=True, null=True,
verbose_name='Route distinguisher' verbose_name='Route distinguisher',
help_text='Unique route distinguisher (as defined in RFC 4364)'
) )
tenant = models.ForeignKey( tenant = models.ForeignKey(
to='tenancy.Tenant', to='tenancy.Tenant',
@ -364,7 +365,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = [ 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 = [ clone_fields = [
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
@ -635,7 +636,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = [ 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', 'dns_name', 'description',
] ]
clone_fields = [ clone_fields = [
@ -925,7 +926,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem) 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 = [ clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description', 'site', 'group', 'tenant', 'status', 'role', 'description',
] ]
@ -1017,7 +1018,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
choices=ServiceProtocolChoices choices=ServiceProtocolChoices
) )
port = models.PositiveIntegerField( 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' verbose_name='Port number'
) )
ipaddresses = models.ManyToManyField( ipaddresses = models.ManyToManyField(

View File

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

View File

@ -3,58 +3,95 @@
{% load form_helpers %} {% load form_helpers %}
{% block content %} {% block content %}
<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
{% block tabs %}{% endblock %} {% block tabs %}{% endblock %}
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-8 col-md-offset-2">
{% if form.non_field_errors %} <h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
<div class="panel panel-danger"> {% if form.non_field_errors %}
<div class="panel-heading"><strong>Errors</strong></div> <div class="panel panel-danger">
<div class="panel-body"> <div class="panel-heading"><strong>Errors</strong></div>
{{ form.non_field_errors }} <div class="panel-body">
{{ form.non_field_errors }}
</div>
</div> </div>
</div> {% endif %}
{% endif %} <ul class="nav nav-tabs" role="tablist">
<form action="" method="post" class="form"> <li role="presentation" class="active"><a href="#csv" role="tab" data-toggle="tab">CSV</a></li>
{% csrf_token %} </ul>
{% render_form form %} <div class="tab-content">
<div class="form-group"> <div role="tabpanel" class="tab-pane active" id="csv">
<div class="col-md-12 text-right"> <form action="" method="post" class="form">
<button type="submit" class="btn btn-primary">Submit</button> {% csrf_token %}
{% if return_url %} {% render_form form %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a> <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 %} {% endif %}
</div> </div>
</div> </div>
</form> </div>
</div> </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>
{% endblock %} {% endblock %}

View File

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

View File

@ -8,6 +8,7 @@ import yaml
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput 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.db.models import Count
from django.forms import BoundField from django.forms import BoundField
from django.forms.models import fields_for_model from django.forms.models import fields_for_model
@ -400,15 +401,22 @@ class TimePicker(forms.TextInput):
class CSVDataField(forms.CharField): class CSVDataField(forms.CharField):
""" """
A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
column headers to values. Each dictionary represents an individual record. 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 widget = forms.Textarea
def __init__(self, fields, required_fields=[], *args, **kwargs): def __init__(self, from_form, *args, **kwargs):
self.fields = fields form = from_form()
self.required_fields = required_fields 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) super().__init__(*args, **kwargs)
@ -416,7 +424,7 @@ class CSVDataField(forms.CharField):
if not self.label: if not self.label:
self.label = '' self.label = ''
if not self.initial: if not self.initial:
self.initial = ','.join(required_fields) + '\n' self.initial = ','.join(self.required_fields) + '\n'
if not self.help_text: if not self.help_text:
self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ 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 ' \ '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): def to_python(self, value):
records = [] records = []
reader = csv.reader(StringIO(value)) reader = csv.reader(StringIO(value.strip()))
# Consume and validate the first line of CSV data as column headers # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
headers = next(reader) # "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: for f in self.required_fields:
if f not in headers: if f not in headers:
raise forms.ValidationError('Required column header "{}" not found.'.format(f)) raise forms.ValidationError(f'Required column header "{f}" not found.')
for f in headers:
if f not in self.fields:
raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
# Parse CSV data return value
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
class CSVChoiceField(forms.ChoiceField): class CSVChoiceField(forms.ChoiceField):
""" """
Invert the provided set of choices to take the human-friendly label as input, and return the database value. 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): def __init__(self, choices, *args, **kwargs):
super().__init__(choices=choices, *args, **kwargs) super().__init__(choices=choices, *args, **kwargs)
self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] 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] 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): class ExpandableNameField(forms.CharField):
""" """
A field which allows for numeric range expansion 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) 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): class SlugField(forms.SlugField):
""" """
Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. 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 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): class ImportForm(BootstrapMixin, forms.Form):
""" """
Generic form for creating an object from JSON/YAML data Generic form for creating an object from JSON/YAML data

View File

@ -116,28 +116,6 @@ def humanize_speed(speed):
return '{} Kbps'.format(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() @register.filter()
def tzoffset(value): def tzoffset(value):
""" """

View File

@ -1,6 +1,8 @@
from django import forms from django import forms
from django.test import TestCase from django.test import TestCase
from ipam.forms import IPAddressCSVForm
from ipam.models import VRF
from utilities.forms import * from utilities.forms import *
@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a,,b]a')) 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): 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): 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) return ImportForm(*args, **kwargs)
@ -609,8 +609,10 @@ class BulkImportView(GetReturnURLMixin, View):
try: try:
# Iterate through CSV data and bind each row to a new model form instance. # Iterate through CSV data and bind each row to a new model form instance.
with transaction.atomic(): with transaction.atomic():
for row, data in enumerate(form.cleaned_data['csv'], start=1): headers, records = form.cleaned_data['csv']
obj_form = self.model_form(data) for row, data in enumerate(records, start=1):
obj_form = self.model_form(data, headers=headers)
if obj_form.is_valid(): if obj_form.is_valid():
obj = self._save_obj(obj_form, request) obj = self._save_obj(obj_form, request)
new_objs.append(obj) new_objs.append(obj)

View File

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