mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
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:
commit
9312dea2b2
@ -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:
|
||||||
|
@ -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
@ -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 = [
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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(
|
||||||
|
@ -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'
|
||||||
|
@ -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">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if field.to_field_name %}
|
||||||
|
<code>{{ field.to_field_name }}</code>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</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 %}
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
Loading…
Reference in New Issue
Block a user