mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 10:58:37 -06:00
make model choice fields work inside csv cable import
This commit is contained in:
parent
0e8fb136c3
commit
3c13f81c83
@ -23,6 +23,7 @@ from tenancy.models import Tenant, TenantGroup
|
|||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||||
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
|
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
|
||||||
|
DeferredCSVModelChoiceField,
|
||||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||||
BOOLEAN_WITH_BLANK_CHOICES,
|
BOOLEAN_WITH_BLANK_CHOICES,
|
||||||
@ -3824,15 +3825,19 @@ class CableCSVForm(CustomFieldModelCSVForm):
|
|||||||
limit_choices_to=CABLE_TERMINATION_PARENT_MODELS,
|
limit_choices_to=CABLE_TERMINATION_PARENT_MODELS,
|
||||||
help_text='Side A parent type'
|
help_text='Side A parent type'
|
||||||
)
|
)
|
||||||
side_a_parent_id = forms.IntegerField(
|
side_a_parent = DeferredCSVModelChoiceField(
|
||||||
help_text='Side A parent ID'
|
queryset=Device.objects.none(), # this is a standin
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Side A parent name'
|
||||||
)
|
)
|
||||||
side_a_type = CSVContentTypeField(
|
side_a_type = CSVContentTypeField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||||
help_text='Side A type'
|
help_text='Side A type'
|
||||||
)
|
)
|
||||||
side_a_name = forms.CharField(
|
side_a = DeferredCSVModelChoiceField(
|
||||||
|
queryset=Interface.objects.none(), # this is a standin
|
||||||
|
to_field_name='name',
|
||||||
help_text='Side A component name'
|
help_text='Side A component name'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3842,15 +3847,19 @@ class CableCSVForm(CustomFieldModelCSVForm):
|
|||||||
limit_choices_to=CABLE_TERMINATION_PARENT_MODELS,
|
limit_choices_to=CABLE_TERMINATION_PARENT_MODELS,
|
||||||
help_text='Side B parent type'
|
help_text='Side B parent type'
|
||||||
)
|
)
|
||||||
side_b_parent_id = forms.IntegerField(
|
side_b_parent = DeferredCSVModelChoiceField(
|
||||||
help_text='Side B parent ID'
|
queryset=Device.objects.none(), # this is a standin
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Side B parent name'
|
||||||
)
|
)
|
||||||
side_b_type = CSVContentTypeField(
|
side_b_type = CSVContentTypeField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||||
help_text='Side B type'
|
help_text='Side B type'
|
||||||
)
|
)
|
||||||
side_b_name = forms.CharField(
|
side_b = DeferredCSVModelChoiceField(
|
||||||
|
queryset=Interface.objects.none(), # this is a standin
|
||||||
|
to_field_name='name',
|
||||||
help_text='Side B component name'
|
help_text='Side B component name'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3874,24 +3883,14 @@ class CableCSVForm(CustomFieldModelCSVForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
fields = [
|
||||||
'side_a_parent_type', 'side_a_parent_id', 'side_a_type', 'side_a_name',
|
'side_a_parent_type', 'side_a_parent', 'side_a_type', 'side_a',
|
||||||
'side_b_parent_type', 'side_b_parent_id', 'side_b_type', 'side_b_name',
|
'side_b_parent_type', 'side_b_parent', 'side_b_type', 'side_b',
|
||||||
'type', 'status', 'label', 'color', 'length', 'length_unit',
|
'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_parent(self, side):
|
|
||||||
parent_type = self.cleaned_data.get(f'side_{side}_parent_type')
|
|
||||||
parent_id = self.cleaned_data.get(f'side_{side}_parent_id')
|
|
||||||
if not parent_type or not parent_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
model = parent_type.model_class()
|
|
||||||
|
|
||||||
return model.objects.get(pk=parent_id)
|
|
||||||
|
|
||||||
def _translate_model(self, parent_type):
|
def _translate_model(self, parent_type):
|
||||||
# TODO: maybe we went overboard with making this generic/extensible?
|
# TODO: maybe we went overboard with making this generic/extensible?
|
||||||
return {
|
return {
|
||||||
@ -3907,19 +3906,19 @@ class CableCSVForm(CustomFieldModelCSVForm):
|
|||||||
assert side in 'ab', f"Invalid side designation: {side}"
|
assert side in 'ab', f"Invalid side designation: {side}"
|
||||||
|
|
||||||
parent_type = self.cleaned_data.get(f'side_{side}_parent_type')
|
parent_type = self.cleaned_data.get(f'side_{side}_parent_type')
|
||||||
parent = self._get_parent(side)
|
parent = self.cleaned_data.get(f'side_{side}_parent')
|
||||||
content_type = self.cleaned_data.get(f'side_{side}_type')
|
content_type = self.cleaned_data.get(f'side_{side}_type')
|
||||||
name = self.cleaned_data.get(f'side_{side}_name')
|
if not parent or not content_type:
|
||||||
if not parent or not content_type or not name:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model = content_type.model_class()
|
model = content_type.model_class()
|
||||||
|
field = self.fields[f'side_{side}']
|
||||||
try:
|
try:
|
||||||
# the parent is named like the model, we utilize that fact for the query
|
# the parent is named like the model, we utilize that fact for the query
|
||||||
termination_object = model.objects.get(**{
|
termination_filter = model.objects.filter(**{
|
||||||
"name" if hasattr(model, "name") else "pk": name,
|
|
||||||
self._translate_model(parent_type): parent
|
self._translate_model(parent_type): parent
|
||||||
})
|
})
|
||||||
|
termination_object = field.get_value(termination_filter)
|
||||||
if termination_object.cable is not None:
|
if termination_object.cable is not None:
|
||||||
raise forms.ValidationError(f"Side {side.upper()}: {parent} {termination_object} is already connected")
|
raise forms.ValidationError(f"Side {side.upper()}: {parent} {termination_object} is already connected")
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
@ -3928,12 +3927,31 @@ class CableCSVForm(CustomFieldModelCSVForm):
|
|||||||
setattr(self.instance, f'termination_{side}', termination_object)
|
setattr(self.instance, f'termination_{side}', termination_object)
|
||||||
return termination_object
|
return termination_object
|
||||||
|
|
||||||
def clean_side_a_name(self):
|
def clean_side_a(self):
|
||||||
return self._clean_side('a')
|
return self._clean_side('a')
|
||||||
|
|
||||||
def clean_side_b_name(self):
|
def clean_side_b(self):
|
||||||
return self._clean_side('b')
|
return self._clean_side('b')
|
||||||
|
|
||||||
|
def _clean_side_parent(self, side):
|
||||||
|
"""
|
||||||
|
Derive a Cable's A/B termination parent.
|
||||||
|
|
||||||
|
:param side: 'a' or 'b'
|
||||||
|
"""
|
||||||
|
assert side in 'ab', f"Invalid side designation: {side}"
|
||||||
|
|
||||||
|
parent_type = self.cleaned_data.get(f'side_{side}_parent_type')
|
||||||
|
field = self.fields[f'side_{side}_parent']
|
||||||
|
|
||||||
|
return field.get_value(parent_type.model_class().objects.all())
|
||||||
|
|
||||||
|
def clean_side_a_parent(self):
|
||||||
|
return self._clean_side_parent('a')
|
||||||
|
|
||||||
|
def clean_side_b_parent(self):
|
||||||
|
return self._clean_side_parent('b')
|
||||||
|
|
||||||
def clean_length_unit(self):
|
def clean_length_unit(self):
|
||||||
# Avoid trying to save as NULL
|
# Avoid trying to save as NULL
|
||||||
length_unit = self.cleaned_data.get('length_unit', None)
|
length_unit = self.cleaned_data.get('length_unit', None)
|
||||||
|
@ -112,8 +112,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
|
|||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'side_a_parent_type', 'side_a_parent_id', 'side_a_type', 'side_a_name',
|
'side_a_parent_type', 'side_a_parent.id', 'side_a_type', 'side_a.id',
|
||||||
'side_b_parent_type', 'side_b_parent_id', 'side_b_type', 'side_b_name',
|
'side_b_parent_type', 'side_b_parent.id', 'side_b_type', 'side_b.id',
|
||||||
'type', 'status', 'label', 'color', 'length', 'length_unit',
|
'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -285,11 +285,11 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
|
|||||||
'{}.{}'.format(self.termination_a.parent._meta.app_label, self.termination_a.parent._meta.model_name),
|
'{}.{}'.format(self.termination_a.parent._meta.app_label, self.termination_a.parent._meta.model_name),
|
||||||
self.termination_a.parent.id,
|
self.termination_a.parent.id,
|
||||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||||
self.termination_a.name if hasattr(self.termination_a, "name") else self.termination_a_id,
|
self.termination_a_id,
|
||||||
'{}.{}'.format(self.termination_b.parent._meta.app_label, self.termination_b.parent._meta.model_name),
|
'{}.{}'.format(self.termination_b.parent._meta.app_label, self.termination_b.parent._meta.model_name),
|
||||||
self.termination_b.parent.id,
|
self.termination_b.parent.id,
|
||||||
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
|
||||||
self.termination_b.name if hasattr(self.termination_b, "name") else self.termination_b_id,
|
self.termination_b_id,
|
||||||
self.type,
|
self.type,
|
||||||
self.status,
|
self.status,
|
||||||
self.label,
|
self.label,
|
||||||
|
@ -1676,10 +1676,10 @@ class CableTestCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"side_a_parent_type,side_a_parent_id,side_a_type,side_a_name,side_b_parent_type,side_b_parent_id,side_b_type,side_b_name",
|
"side_a_parent_type,side_a_parent,side_a_type,side_a,side_b_parent_type,side_b_parent.id,side_b_type,side_b",
|
||||||
f"dcim.device,{devices[2].pk},dcim.interface,Interface 1,dcim.device,{devices[3].pk},dcim.interface,Interface 1",
|
f"dcim.device,Device 3,dcim.interface,Interface 1,dcim.device,{devices[3].pk},dcim.interface,Interface 1",
|
||||||
f"dcim.device,{devices[2].pk},dcim.interface,Interface 2,dcim.device,{devices[3].pk},dcim.interface,Interface 2",
|
f"dcim.device,Device 3,dcim.interface,Interface 2,dcim.device,{devices[3].pk},dcim.interface,Interface 2",
|
||||||
f"dcim.device,{devices[2].pk},dcim.interface,Interface 3,dcim.device,{devices[3].pk},dcim.interface,Interface 3",
|
f"dcim.device,Device 3,dcim.interface,Interface 3,dcim.device,{devices[3].pk},dcim.interface,Interface 3",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
|
@ -24,6 +24,7 @@ __all__ = (
|
|||||||
'CSVContentTypeField',
|
'CSVContentTypeField',
|
||||||
'CSVDataField',
|
'CSVDataField',
|
||||||
'CSVModelChoiceField',
|
'CSVModelChoiceField',
|
||||||
|
'DeferredCSVModelChoiceField',
|
||||||
'DynamicModelChoiceField',
|
'DynamicModelChoiceField',
|
||||||
'DynamicModelMultipleChoiceField',
|
'DynamicModelMultipleChoiceField',
|
||||||
'ExpandableIPAddressField',
|
'ExpandableIPAddressField',
|
||||||
@ -142,6 +143,29 @@ class CSVModelChoiceField(forms.ModelChoiceField):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeferredCSVModelChoiceField(CSVModelChoiceField):
|
||||||
|
"""
|
||||||
|
Allows to defer querying from to_python
|
||||||
|
"""
|
||||||
|
default_error_messages = {
|
||||||
|
'invalid_choice': 'Object not found.',
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_value(self, qs):
|
||||||
|
self.queryset = qs
|
||||||
|
try:
|
||||||
|
return super().to_python(self.value)
|
||||||
|
except MultipleObjectsReturned:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f'"{self.value}" is not a unique value for this field; multiple objects were found'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CSVContentTypeField(CSVModelChoiceField):
|
class CSVContentTypeField(CSVModelChoiceField):
|
||||||
"""
|
"""
|
||||||
Reference a ContentType in the form <app>.<model>
|
Reference a ContentType in the form <app>.<model>
|
||||||
|
Loading…
Reference in New Issue
Block a user