Skip to content

Commit 53d1b1a

Browse files
Closes #19944: Add multi-scenario CSV import testing support with cleanup (#20302)
* Closes #19944: Add multi-scenario CSV import testing support with cleanup Enhanced BulkImportObjectsViewTestCase to support multiple CSV import scenarios via dictionary format, where each scenario runs as a separate subtest with automatic cleanup. This enables testing different import configurations (e.g., with/without optional fields) in a single test run with clear output showing which scenario is being tested. Introduces cleanupSubTest() context manager that uses database savepoints to automatically roll back changes between subtests, providing test isolation similar to separate test methods. This allows subtests to create/modify objects without affecting subsequent subtests in the same test method. Added post_import_callback parameter to bulk import tests, allowing child classes to inject custom assertions that run before database cleanup. This solves the inheritance problem where child classes need to verify imported data but the parent's cleanup would roll back the data before assertions could run. The callback approach is cleaner than conditional cleanup parameters - it makes the execution timing explicit and maintains test isolation while still allowing extensibility. * Fixup ModuleTypeTestCase bulk import test to work with callback mechamisn * Update CableTestCase to use expanded CSV scenario testing * Remove unneeded permission cleanup Co-authored-by: Jeremy Stretch <[email protected]> * Consolidate scenario name retrieval into method --------- Co-authored-by: Jeremy Stretch <[email protected]>
1 parent d172e62 commit 53d1b1a

File tree

3 files changed

+173
-55
lines changed

3 files changed

+173
-55
lines changed

netbox/dcim/tests/test_views.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,14 +1078,14 @@ def test_bulk_import_objects_with_permission(self):
10781078
'dcim.add_modulebaytemplate',
10791079
)
10801080

1081-
# run base test
1082-
super().test_bulk_import_objects_with_permission()
1083-
1084-
# TODO: remove extra regression asserts once parent test supports testing all import fields
1085-
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
1086-
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
1081+
def verify_module_type_profile(scenario_name):
1082+
# TODO: remove extra regression asserts once parent test supports testing all import fields
1083+
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
1084+
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
1085+
assert fan_module_type.profile == fan_module_type_profile
10871086

1088-
assert fan_module_type.profile == fan_module_type_profile
1087+
# run base test
1088+
super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
10891089

10901090
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
10911091
def test_bulk_import_objects_with_constrained_permission(self):
@@ -3290,8 +3290,10 @@ def setUpTestData(cls):
32903290
Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
32913291
Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
32923292
Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
3293+
Device(name='Device 4', site=sites[0], device_type=devicetype, role=role),
32933294
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
32943295
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
3296+
Device(name='Device 5', site=sites[1], device_type=devicetype, role=role),
32953297
)
32963298
Device.objects.bulk_create(devices)
32973299

@@ -3300,22 +3302,36 @@ def setUpTestData(cls):
33003302
vc.save()
33013303

33023304
interfaces = (
3305+
# Device 1, Site 1
33033306
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33043307
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33053308
Interface(device=devices[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3309+
# Device 2, Site 1
33063310
Interface(device=devices[1], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33073311
Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33083312
Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3313+
# Device 3, Site 1
33093314
Interface(device=devices[2], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33103315
Interface(device=devices[2], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33113316
Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3317+
# Device 3, Site 1
33123318
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33133319
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33143320
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3321+
# Device 1, Site 2
3322+
Interface(device=devices[4], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3323+
Interface(device=devices[4], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3324+
Interface(device=devices[4], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3325+
3326+
# Device 1, Site 2
3327+
Interface(device=devices[5], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3328+
Interface(device=devices[5], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3329+
Interface(device=devices[5], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3330+
33153331
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33163332
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3317-
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3318-
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3333+
Interface(device=devices[4], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
3334+
Interface(device=devices[4], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
33193335
)
33203336
Interface.objects.bulk_create(interfaces)
33213337

@@ -3342,16 +3358,29 @@ def setUpTestData(cls):
33423358
'tags': [t.pk for t in tags],
33433359
}
33443360

3345-
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
3346-
# the same device name, provided those devices belong to different sites.
3347-
cls.csv_data = (
3348-
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
3349-
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
3350-
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
3351-
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
3352-
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
3353-
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
3354-
)
3361+
cls.csv_data = {
3362+
'default': (
3363+
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
3364+
"Device 4,dcim.interface,Interface 1,Device 5,dcim.interface,Interface 1",
3365+
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
3366+
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
3367+
3368+
# The following is no longer possible in this scenario, because there are multiple
3369+
# devices named "Device 1" across multiple sites. See the "site-filtering" scenario
3370+
# below for how to specify a site for non-unique device names.
3371+
# "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
3372+
),
3373+
'site-filtering': (
3374+
# Ensure that CSV bulk import supports assigning terminations from parent devices
3375+
# that share the same device name, provided those devices belong to different sites.
3376+
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
3377+
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
3378+
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
3379+
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
3380+
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
3381+
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
3382+
)
3383+
}
33553384

33563385
cls.csv_update_data = (
33573386
"id,label,color",

netbox/utilities/testing/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
2+
from contextlib import contextmanager
23

34
from django.contrib.contenttypes.fields import GenericForeignKey
45
from django.contrib.contenttypes.models import ContentType
56
from django.contrib.postgres.fields import ArrayField, RangeField
67
from django.core.exceptions import FieldDoesNotExist
8+
from django.db import transaction
79
from django.db.models import ManyToManyField, ManyToManyRel, JSONField
810
from django.forms.models import model_to_dict
911
from django.test import Client, TestCase as _TestCase
@@ -36,6 +38,20 @@ def setUp(self):
3638
self.client = Client()
3739
self.client.force_login(self.user)
3840

41+
@contextmanager
42+
def cleanupSubTest(self, **params):
43+
"""
44+
Context manager that wraps subTest with automatic cleanup.
45+
All database changes within the context will be rolled back.
46+
"""
47+
sid = transaction.savepoint()
48+
49+
try:
50+
with self.subTest(**params):
51+
yield
52+
finally:
53+
transaction.savepoint_rollback(sid)
54+
3955
#
4056
# Permissions management
4157
#

netbox/utilities/testing/views.py

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ def test_create_object_without_permission(self):
152152

153153
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
154154
def test_create_object_with_permission(self):
155-
156155
# Assign unconstrained permission
157156
obj_perm = ObjectPermission(
158157
name='Test permission',
@@ -586,19 +585,59 @@ def test_create_multiple_objects_with_constrained_permission(self):
586585
response = self.client.post(**request)
587586
self.assertHttpStatus(response, 302)
588587
self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count())
589-
for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]:
588+
for instance in self._get_queryset().order_by('-pk')[: self.bulk_create_count]:
590589
self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields)
591590

592591
class BulkImportObjectsViewTestCase(ModelViewTestCase):
593592
"""
594593
Create multiple instances from imported data.
595594
596-
:csv_data: A list of CSV-formatted lines (starting with the headers) to be used for bulk object import.
595+
:csv_data: CSV data for bulk import testing. Supports two formats:
596+
597+
1. Tuple/list format (backwards compatible):
598+
csv_data = (
599+
"name,slug,description",
600+
"Object 1,object-1,First object",
601+
"Object 2,object-2,Second object",
602+
)
603+
604+
2. Dictionary format for multiple scenarios:
605+
csv_data = {
606+
'default': (
607+
"name,slug,description",
608+
"Object 1,object-1,First object",
609+
),
610+
'with_optional_fields': (
611+
"name,slug,description,comments",
612+
"Object 2,object-2,Second object,With comments",
613+
)
614+
}
615+
616+
When using dictionary format, test_bulk_import_objects_with_permission()
617+
runs each scenario as a separate subtest with clear output:
618+
619+
test_bulk_import_objects_with_permission (scenario=default) ... ok
620+
test_bulk_import_objects_with_permission (scenario=with_optional_fields) ... ok
597621
"""
622+
598623
csv_data = ()
599624

600-
def _get_csv_data(self):
601-
return '\n'.join(self.csv_data)
625+
def get_scenarios(self):
626+
return self.csv_data.keys() if isinstance(self.csv_data, dict) else ['default']
627+
628+
def _get_csv_data(self, scenario_name='default'):
629+
"""
630+
Get CSV data for testing. Supports both tuple/list and dictionary formats.
631+
"""
632+
if isinstance(self.csv_data, dict):
633+
if scenario_name not in self.csv_data:
634+
available = ', '.join(self.csv_data.keys())
635+
raise ValueError(f"Scenario '{scenario_name}' not found in csv_data. Available: {available}")
636+
return '\n'.join(self.csv_data[scenario_name])
637+
elif isinstance(self.csv_data, (tuple, list)):
638+
return '\n'.join(self.csv_data)
639+
else:
640+
raise TypeError(f'csv_data must be a tuple, list, or dictionary, got {type(self.csv_data)}')
602641

603642
def _get_update_csv_data(self):
604643
return self.csv_update_data, '\n'.join(self.csv_update_data)
@@ -620,10 +659,36 @@ def test_bulk_import_objects_without_permission(self):
620659
self.assertHttpStatus(response, 403)
621660

622661
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
623-
def test_bulk_import_objects_with_permission(self):
662+
def test_bulk_import_objects_with_permission(self, post_import_callback=None):
663+
# Assign model-level permission once for all scenarios
664+
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
665+
obj_perm.save()
666+
obj_perm.users.add(self.user)
667+
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
668+
669+
# Try GET with model-level permission (only once)
670+
self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
671+
672+
# Test each scenario
673+
for scenario_name in self.get_scenarios():
674+
with self.cleanupSubTest(scenario=scenario_name):
675+
self._test_bulk_import_with_permission_scenario(scenario_name)
676+
677+
if post_import_callback:
678+
post_import_callback(scenario_name)
679+
680+
def _test_bulk_import_with_permission_scenario(self, scenario_name):
681+
"""
682+
Helper method to test a single bulk import scenario.
683+
"""
624684
initial_count = self._get_queryset().count()
685+
686+
# Get CSV data for this scenario
687+
scenario_data = self._get_csv_data(scenario_name)
688+
expected_new_objects = len(scenario_data.splitlines()) - 1
689+
625690
data = {
626-
'data': self._get_csv_data(),
691+
'data': scenario_data,
627692
'format': ImportFormatChoices.CSV,
628693
'csv_delimiter': CSVDelimiterChoices.AUTO,
629694
}
@@ -632,33 +697,24 @@ def test_bulk_import_objects_with_permission(self):
632697
if issubclass(self.model, ChangeLoggingMixin):
633698
data['changelog_message'] = get_random_string(10)
634699

635-
# Assign model-level permission
636-
obj_perm = ObjectPermission(
637-
name='Test permission',
638-
actions=['add']
639-
)
640-
obj_perm.save()
641-
obj_perm.users.add(self.user)
642-
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
643-
644-
# Try GET with model-level permission
645-
self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
646-
647700
# Test POST with permission
648701
response = self.client.post(self._get_url('bulk_import'), data)
649702
self.assertHttpStatus(response, 302)
650-
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
703+
704+
# Verify object count increase
705+
self.assertEqual(self._get_queryset().count(), initial_count + expected_new_objects)
651706

652707
# Verify ObjectChange creation
653708
if issubclass(self.model, ChangeLoggingMixin):
654709
request_id = response.headers.get('X-Request-ID')
655-
self.assertIsNotNone(request_id, "Unable to determine request ID from response")
710+
self.assertIsNotNone(request_id, 'Unable to determine request ID from response')
656711
objectchanges = ObjectChange.objects.filter(
657712
changed_object_type=ContentType.objects.get_for_model(self.model),
658713
request_id=request_id,
659714
action=ObjectChangeActionChoices.ACTION_CREATE,
660715
)
661-
self.assertEqual(len(objectchanges), len(self.csv_data) - 1)
716+
self.assertEqual(len(objectchanges), expected_new_objects)
717+
662718
for oc in objectchanges:
663719
self.assertEqual(oc.message, data['changelog_message'])
664720

@@ -701,35 +757,52 @@ def test_bulk_update_objects_with_permission(self):
701757
self.assertEqual(value, value)
702758

703759
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
704-
def test_bulk_import_objects_with_constrained_permission(self):
705-
initial_count = self._get_queryset().count()
706-
data = {
707-
'data': self._get_csv_data(),
708-
'format': ImportFormatChoices.CSV,
709-
'csv_delimiter': CSVDelimiterChoices.AUTO,
710-
}
711-
712-
# Assign constrained permission
760+
def test_bulk_import_objects_with_constrained_permission(self, post_import_callback=None):
761+
# Assign constrained permission (deny all initially)
713762
obj_perm = ObjectPermission(
714763
name='Test permission',
715764
constraints={'pk': 0}, # Dummy permission to deny all
716-
actions=['add']
765+
actions=['add'],
717766
)
718767
obj_perm.save()
719768
obj_perm.users.add(self.user)
720769
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
721770

722-
# Attempt to import non-permitted objects
771+
# Test each scenario with constrained permissions
772+
for scenario_name in self.get_scenarios():
773+
with self.cleanupSubTest(scenario=scenario_name):
774+
self._test_bulk_import_constrained_scenario(scenario_name, obj_perm)
775+
776+
if post_import_callback:
777+
post_import_callback(scenario_name)
778+
779+
def _test_bulk_import_constrained_scenario(self, scenario_name, obj_perm):
780+
"""
781+
Helper method to test a single bulk import scenario with constrained permissions.
782+
"""
783+
initial_count = self._get_queryset().count()
784+
785+
# Get CSV data for this scenario
786+
scenario_data = self._get_csv_data(scenario_name)
787+
expected_new_objects = len(scenario_data.splitlines()) - 1
788+
789+
data = {
790+
'data': scenario_data,
791+
'format': ImportFormatChoices.CSV,
792+
'csv_delimiter': CSVDelimiterChoices.AUTO,
793+
}
794+
795+
# Attempt to import non-permitted objects (should fail)
723796
self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 200)
724797
self.assertEqual(self._get_queryset().count(), initial_count)
725798

726-
# Update permission constraints
799+
# Update permission constraints to allow all
727800
obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all
728801
obj_perm.save()
729802

730-
# Import permitted objects
803+
# Import permitted objects (should succeed)
731804
self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
732-
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
805+
self.assertEqual(self._get_queryset().count(), initial_count + expected_new_objects)
733806

734807
class BulkEditObjectsViewTestCase(ModelViewTestCase):
735808
"""

0 commit comments

Comments
 (0)