Skip to content

Commit 730d730

Browse files
authored
Merge pull request #20717 from m-hau/bugfix/related-object-validation
Fixes: #20670: Related Object Validation
2 parents 6c2a6d0 + fbe76ac commit 730d730

File tree

17 files changed

+182
-36
lines changed

17 files changed

+182
-36
lines changed

netbox/dcim/tests/test_views.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,131 @@ def test_import_objects(self):
986986
ii1 = InventoryItemTemplate.objects.first()
987987
self.assertEqual(ii1.name, 'Inventory Item 1')
988988

989+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
990+
def test_import_error_numbering(self):
991+
# Add all required permissions to the test user
992+
self.add_permissions(
993+
'dcim.view_devicetype',
994+
'dcim.add_devicetype',
995+
'dcim.add_consoleporttemplate',
996+
'dcim.add_consoleserverporttemplate',
997+
'dcim.add_powerporttemplate',
998+
'dcim.add_poweroutlettemplate',
999+
'dcim.add_interfacetemplate',
1000+
'dcim.add_frontporttemplate',
1001+
'dcim.add_rearporttemplate',
1002+
'dcim.add_modulebaytemplate',
1003+
'dcim.add_devicebaytemplate',
1004+
'dcim.add_inventoryitemtemplate',
1005+
)
1006+
1007+
import_data = '''
1008+
---
1009+
manufacturer: Manufacturer 1
1010+
model: TEST-2001
1011+
slug: test-2001
1012+
u_height: 1
1013+
module-bays:
1014+
- name: Module Bay 1-1
1015+
- name: Module Bay 1-2
1016+
---
1017+
- manufacturer: Manufacturer 1
1018+
model: TEST-2002
1019+
slug: test-2002
1020+
u_height: 1
1021+
module-bays:
1022+
- name: Module Bay 2-1
1023+
- name: Module Bay 2-2
1024+
- not_name: Module Bay 2-3
1025+
- manufacturer: Manufacturer 1
1026+
model: TEST-2003
1027+
slug: test-2003
1028+
u_height: 1
1029+
module-bays:
1030+
- name: Module Bay 3-1
1031+
'''
1032+
form_data = {
1033+
'data': import_data,
1034+
'format': 'yaml'
1035+
}
1036+
1037+
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
1038+
self.assertHttpStatus(response, 200)
1039+
self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
1040+
1041+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
1042+
def test_import_nolist(self):
1043+
# Add all required permissions to the test user
1044+
self.add_permissions(
1045+
'dcim.view_devicetype',
1046+
'dcim.add_devicetype',
1047+
'dcim.add_consoleporttemplate',
1048+
'dcim.add_consoleserverporttemplate',
1049+
'dcim.add_powerporttemplate',
1050+
'dcim.add_poweroutlettemplate',
1051+
'dcim.add_interfacetemplate',
1052+
'dcim.add_frontporttemplate',
1053+
'dcim.add_rearporttemplate',
1054+
'dcim.add_modulebaytemplate',
1055+
'dcim.add_devicebaytemplate',
1056+
'dcim.add_inventoryitemtemplate',
1057+
)
1058+
1059+
for value in ('', 'null', '3', '"My console port"', '{name: "My other console port"}'):
1060+
with self.subTest(value=value):
1061+
import_data = f'''
1062+
manufacturer: Manufacturer 1
1063+
model: TEST-3000
1064+
slug: test-3000
1065+
u_height: 1
1066+
console-ports: {value}
1067+
'''
1068+
form_data = {
1069+
'data': import_data,
1070+
'format': 'yaml'
1071+
}
1072+
1073+
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
1074+
self.assertHttpStatus(response, 200)
1075+
self.assertContains(response, "Record 1 console-ports: Must be a list.")
1076+
1077+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
1078+
def test_import_nodict(self):
1079+
# Add all required permissions to the test user
1080+
self.add_permissions(
1081+
'dcim.view_devicetype',
1082+
'dcim.add_devicetype',
1083+
'dcim.add_consoleporttemplate',
1084+
'dcim.add_consoleserverporttemplate',
1085+
'dcim.add_powerporttemplate',
1086+
'dcim.add_poweroutlettemplate',
1087+
'dcim.add_interfacetemplate',
1088+
'dcim.add_frontporttemplate',
1089+
'dcim.add_rearporttemplate',
1090+
'dcim.add_modulebaytemplate',
1091+
'dcim.add_devicebaytemplate',
1092+
'dcim.add_inventoryitemtemplate',
1093+
)
1094+
1095+
for value in ('', 'null', '3', '"My console port"', '["My other console port"]'):
1096+
with self.subTest(value=value):
1097+
import_data = f'''
1098+
manufacturer: Manufacturer 1
1099+
model: TEST-4000
1100+
slug: test-4000
1101+
u_height: 1
1102+
console-ports:
1103+
- {value}
1104+
'''
1105+
form_data = {
1106+
'data': import_data,
1107+
'format': 'yaml'
1108+
}
1109+
1110+
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
1111+
self.assertHttpStatus(response, 200)
1112+
self.assertContains(response, "Record 1 console-ports[1]: Must be a dictionary.")
1113+
9891114
def test_export_objects(self):
9901115
url = reverse('dcim:devicetype_list')
9911116
self.add_permissions('dcim.view_devicetype')

netbox/netbox/views/generic/bulk_views.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ def post(self, request):
323323

324324
class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
325325
"""
326-
Import objects in bulk (CSV format).
326+
Import objects in bulk (CSV/JSON/YAML format).
327327
328328
Attributes:
329329
model_form: The form used to create each imported object
@@ -368,7 +368,7 @@ def _compile_form_errors(self, errors, index, prefix=None):
368368
error_messages.append(f"Record {index} {prefix}{field_name}: {err}")
369369
return error_messages
370370

371-
def _save_object(self, model_form, request):
371+
def _save_object(self, model_form, request, parent_idx):
372372
_action = 'Updated' if model_form.instance.pk else 'Created'
373373

374374
# Save the primary object
@@ -381,8 +381,25 @@ def _save_object(self, model_form, request):
381381
# Iterate through the related object forms (if any), validating and saving each instance.
382382
for field_name, related_object_form in self.related_object_forms.items():
383383

384+
related_objects = model_form.data.get(field_name, list())
385+
if not isinstance(related_objects, list):
386+
raise ValidationError(
387+
self._compile_form_errors(
388+
{field_name: [_("Must be a list.")]},
389+
index=parent_idx
390+
)
391+
)
392+
384393
related_obj_pks = []
385-
for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())):
394+
for i, rel_obj_data in enumerate(related_objects, start=1):
395+
if not isinstance(rel_obj_data, dict):
396+
raise ValidationError(
397+
self._compile_form_errors(
398+
{f'{field_name}[{i}]': [_("Must be a dictionary.")]},
399+
index=parent_idx,
400+
)
401+
)
402+
386403
rel_obj_data = self.prep_related_object_data(obj, rel_obj_data)
387404
f = related_object_form(rel_obj_data)
388405

@@ -396,7 +413,7 @@ def _save_object(self, model_form, request):
396413
else:
397414
# Replicate errors on the related object form to the import form for display and abort
398415
raise ValidationError(
399-
self._compile_form_errors(f.errors, index=i, prefix=f'{field_name}[{i}]')
416+
self._compile_form_errors(f.errors, index=parent_idx, prefix=f'{field_name}[{i}]')
400417
)
401418

402419
# Enforce object-level permissions on related objects
@@ -439,8 +456,12 @@ def create_and_update_objects(self, form, request):
439456
try:
440457
instance = prefetched_objects[object_id]
441458
except KeyError:
442-
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
443-
raise ValidationError('')
459+
raise ValidationError(
460+
self._compile_form_errors(
461+
{'id': [_("Object with ID {id} does not exist").format(id=object_id)]},
462+
index=i
463+
)
464+
)
444465

445466
# Take a snapshot for change logging
446467
if instance.pk and hasattr(instance, 'snapshot'):
@@ -481,7 +502,7 @@ def create_and_update_objects(self, form, request):
481502
restrict_form_fields(model_form, request.user)
482503

483504
if model_form.is_valid():
484-
obj = self._save_object(model_form, request)
505+
obj = self._save_object(model_form, request, i)
485506
saved_objects.append(obj)
486507
else:
487508
# Raise model form errors

netbox/translations/cs/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12822,8 +12822,8 @@ msgstr ""
1282212822

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

1282812828
#: netbox/netbox/views/generic/bulk_views.py:525
1282912829
#, python-brace-format

netbox/translations/da/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12857,8 +12857,8 @@ msgstr ""
1285712857

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

1286312863
#: netbox/netbox/views/generic/bulk_views.py:525
1286412864
#, python-brace-format

netbox/translations/de/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13055,8 +13055,8 @@ msgstr ""
1305513055

1305613056
#: netbox/netbox/views/generic/bulk_views.py:442
1305713057
#, python-brace-format
13058-
msgid "Row {i}: Object with ID {id} does not exist"
13059-
msgstr "Reihe {i}: Objekt mit ID {id} existiert nicht"
13058+
msgid "Object with ID {id} does not exist"
13059+
msgstr "Objekt mit ID {id} existiert nicht"
1306013060

1306113061
#: netbox/netbox/views/generic/bulk_views.py:525
1306213062
#, python-brace-format

netbox/translations/en/LC_MESSAGES/django.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12541,7 +12541,7 @@ msgstr ""
1254112541

1254212542
#: netbox/netbox/views/generic/bulk_views.py:442
1254312543
#, python-brace-format
12544-
msgid "Row {i}: Object with ID {id} does not exist"
12544+
msgid "Object with ID {id} does not exist"
1254512545
msgstr ""
1254612546

1254712547
#: netbox/netbox/views/generic/bulk_views.py:525

netbox/translations/es/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12999,8 +12999,8 @@ msgstr ""
1299912999

1300013000
#: netbox/netbox/views/generic/bulk_views.py:442
1300113001
#, python-brace-format
13002-
msgid "Row {i}: Object with ID {id} does not exist"
13003-
msgstr "Fila {i}: Objeto con ID {id} no existe"
13002+
msgid "Object with ID {id} does not exist"
13003+
msgstr "Objeto con ID {id} no existe"
1300413004

1300513005
#: netbox/netbox/views/generic/bulk_views.py:525
1300613006
#, python-brace-format

netbox/translations/fr/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13041,8 +13041,8 @@ msgstr ""
1304113041

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

1304713047
#: netbox/netbox/views/generic/bulk_views.py:525
1304813048
#, python-brace-format

netbox/translations/it/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13033,8 +13033,8 @@ msgstr ""
1303313033

1303413034
#: netbox/netbox/views/generic/bulk_views.py:442
1303513035
#, python-brace-format
13036-
msgid "Row {i}: Object with ID {id} does not exist"
13037-
msgstr "Fila {i}: Oggetto con ID {id} non esiste"
13036+
msgid "Object with ID {id} does not exist"
13037+
msgstr "Oggetto con ID {id} non esiste"
1303813038

1303913039
#: netbox/netbox/views/generic/bulk_views.py:525
1304013040
#, python-brace-format

netbox/translations/ja/LC_MESSAGES/django.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12645,8 +12645,8 @@ msgstr "選択したエクスポートテンプレートをレンダリング中
1264512645

1264612646
#: netbox/netbox/views/generic/bulk_views.py:442
1264712647
#, python-brace-format
12648-
msgid "Row {i}: Object with ID {id} does not exist"
12649-
msgstr "行 {i}: ID {id}のオブジェクトは存在しません"
12648+
msgid "Object with ID {id} does not exist"
12649+
msgstr "ID {id}のオブジェクトは存在しません"
1265012650

1265112651
#: netbox/netbox/views/generic/bulk_views.py:525
1265212652
#, python-brace-format

0 commit comments

Comments
 (0)