Merge pull request #20717 from m-hau/bugfix/related-object-validation

Fixes: #20670: Related Object Validation
This commit is contained in:
bctiemann 2025-11-06 13:49:19 -05:00 committed by GitHub
commit 730d73042d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 182 additions and 36 deletions

View File

@ -986,6 +986,131 @@ inventory-items:
ii1 = InventoryItemTemplate.objects.first() ii1 = InventoryItemTemplate.objects.first()
self.assertEqual(ii1.name, 'Inventory Item 1') self.assertEqual(ii1.name, 'Inventory Item 1')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_error_numbering(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
import_data = '''
---
manufacturer: Manufacturer 1
model: TEST-2001
slug: test-2001
u_height: 1
module-bays:
- name: Module Bay 1-1
- name: Module Bay 1-2
---
- manufacturer: Manufacturer 1
model: TEST-2002
slug: test-2002
u_height: 1
module-bays:
- name: Module Bay 2-1
- name: Module Bay 2-2
- not_name: Module Bay 2-3
- manufacturer: Manufacturer 1
model: TEST-2003
slug: test-2003
u_height: 1
module-bays:
- name: Module Bay 3-1
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_nolist(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
for value in ('', 'null', '3', '"My console port"', '{name: "My other console port"}'):
with self.subTest(value=value):
import_data = f'''
manufacturer: Manufacturer 1
model: TEST-3000
slug: test-3000
u_height: 1
console-ports: {value}
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 1 console-ports: Must be a list.")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_nodict(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
for value in ('', 'null', '3', '"My console port"', '["My other console port"]'):
with self.subTest(value=value):
import_data = f'''
manufacturer: Manufacturer 1
model: TEST-4000
slug: test-4000
u_height: 1
console-ports:
- {value}
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 1 console-ports[1]: Must be a dictionary.")
def test_export_objects(self): def test_export_objects(self):
url = reverse('dcim:devicetype_list') url = reverse('dcim:devicetype_list')
self.add_permissions('dcim.view_devicetype') self.add_permissions('dcim.view_devicetype')

View File

@ -323,7 +323,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
""" """
Import objects in bulk (CSV format). Import objects in bulk (CSV/JSON/YAML format).
Attributes: Attributes:
model_form: The form used to create each imported object model_form: The form used to create each imported object
@ -368,7 +368,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
error_messages.append(f"Record {index} {prefix}{field_name}: {err}") error_messages.append(f"Record {index} {prefix}{field_name}: {err}")
return error_messages return error_messages
def _save_object(self, model_form, request): def _save_object(self, model_form, request, parent_idx):
_action = 'Updated' if model_form.instance.pk else 'Created' _action = 'Updated' if model_form.instance.pk else 'Created'
# Save the primary object # Save the primary object
@ -381,8 +381,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
# Iterate through the related object forms (if any), validating and saving each instance. # Iterate through the related object forms (if any), validating and saving each instance.
for field_name, related_object_form in self.related_object_forms.items(): for field_name, related_object_form in self.related_object_forms.items():
related_objects = model_form.data.get(field_name, list())
if not isinstance(related_objects, list):
raise ValidationError(
self._compile_form_errors(
{field_name: [_("Must be a list.")]},
index=parent_idx
)
)
related_obj_pks = [] related_obj_pks = []
for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): for i, rel_obj_data in enumerate(related_objects, start=1):
if not isinstance(rel_obj_data, dict):
raise ValidationError(
self._compile_form_errors(
{f'{field_name}[{i}]': [_("Must be a dictionary.")]},
index=parent_idx,
)
)
rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) rel_obj_data = self.prep_related_object_data(obj, rel_obj_data)
f = related_object_form(rel_obj_data) f = related_object_form(rel_obj_data)
@ -396,7 +413,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
else: else:
# Replicate errors on the related object form to the import form for display and abort # Replicate errors on the related object form to the import form for display and abort
raise ValidationError( raise ValidationError(
self._compile_form_errors(f.errors, index=i, prefix=f'{field_name}[{i}]') self._compile_form_errors(f.errors, index=parent_idx, prefix=f'{field_name}[{i}]')
) )
# Enforce object-level permissions on related objects # Enforce object-level permissions on related objects
@ -439,8 +456,12 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
try: try:
instance = prefetched_objects[object_id] instance = prefetched_objects[object_id]
except KeyError: except KeyError:
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id)) raise ValidationError(
raise ValidationError('') self._compile_form_errors(
{'id': [_("Object with ID {id} does not exist").format(id=object_id)]},
index=i
)
)
# Take a snapshot for change logging # Take a snapshot for change logging
if instance.pk and hasattr(instance, 'snapshot'): if instance.pk and hasattr(instance, 'snapshot'):
@ -481,7 +502,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
restrict_form_fields(model_form, request.user) restrict_form_fields(model_form, request.user)
if model_form.is_valid(): if model_form.is_valid():
obj = self._save_object(model_form, request) obj = self._save_object(model_form, request, i)
saved_objects.append(obj) saved_objects.append(obj)
else: else:
# Raise model form errors # Raise model form errors

View File

@ -12822,8 +12822,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Řádek {i}: Objekt s ID {id} neexistuje" msgstr "Objekt s ID {id} neexistuje"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12857,8 +12857,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Række {i}: Objekt med ID {id} findes ikke" msgstr "Objekt med ID {id} findes ikke"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -13055,8 +13055,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Reihe {i}: Objekt mit ID {id} existiert nicht" msgstr "Objekt mit ID {id} existiert nicht"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12541,7 +12541,7 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "" msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525

View File

@ -12999,8 +12999,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Fila {i}: Objeto con ID {id} no existe" msgstr "Objeto con ID {id} no existe"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -13041,8 +13041,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Rangée {i}: Objet avec identifiant {id} n'existe pas" msgstr "Objet avec identifiant {id} n'existe pas"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -13033,8 +13033,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Fila {i}: Oggetto con ID {id} non esiste" msgstr "Oggetto con ID {id} non esiste"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12645,8 +12645,8 @@ msgstr "選択したエクスポートテンプレートをレンダリング中
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "行 {i}: ID {id}のオブジェクトは存在しません" msgstr "ID {id}のオブジェクトは存在しません"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -13000,8 +13000,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Rij {i}: Object met ID {id} bestaat niet" msgstr "Object met ID {id} bestaat niet"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12920,8 +12920,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Wiersz {i}: Obiekt z identyfikatorem {id} nie istnieje" msgstr "Obiekt z identyfikatorem {id} nie istnieje"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12944,8 +12944,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Linha {i}: Objeto com ID {id} não existe" msgstr "Objeto com ID {id} não existe"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12939,8 +12939,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Ряд {i}: Объект с идентификатором {id} не существует" msgstr "Объект с идентификатором {id} не существует"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12835,8 +12835,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Satır {i}: Kimliği olan nesne {id} mevcut değil" msgstr "Kimliği olan nesne {id} mevcut değil"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12920,8 +12920,8 @@ msgstr ""
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "Ряд {i}: Об'єкт з ідентифікатором {id} не існує" msgstr "Об'єкт з ідентифікатором {id} не існує"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format

View File

@ -12622,8 +12622,8 @@ msgstr "渲染所选导出模板时出错 ({template}): {error}"
#: netbox/netbox/views/generic/bulk_views.py:442 #: netbox/netbox/views/generic/bulk_views.py:442
#, python-brace-format #, python-brace-format
msgid "Row {i}: Object with ID {id} does not exist" msgid "Object with ID {id} does not exist"
msgstr "第{i}行: ID为{id}的对象不存在" msgstr "ID为{id}的对象不存在"
#: netbox/netbox/views/generic/bulk_views.py:525 #: netbox/netbox/views/generic/bulk_views.py:525
#, python-brace-format #, python-brace-format