Fix UI regression: transform= changed the rendered column, not just CSV export

django-tables2's ManyToManyColumn.render() and NetBox's own value() override
both call self.transform() for each item -- there's no built-in way to give
CSV export a different representation than the rendered column. Setting
transform=lambda obj: obj.name on the three module_bay_types columns to fix
CSV export therefore also dropped the manufacturer prefix from the Bay Types
column in the Module Bays, Module Bay Templates, and Module Types list
views -- the opposite of what ModuleBayType.__str__() adds that prefix for.
Verified directly: with the old transform=, two same-named bay types from
different manufacturers render as visually identical "SFP28" list items.

Add export_transform to NetBox's ManyToManyColumn subclass, defaulting to
transform so existing columns are unaffected, and used only by value()
(export) rather than render() (UI). Switch the three columns to
export_transform=lambda obj: obj.name, leaving transform unset so render()
keeps str()'s manufacturer prefix.

Extended the existing round-trip test to also assert the rendered column
still includes the manufacturer name; confirmed it fails against the old
transform= approach and passes with export_transform=.
This commit is contained in:
Brian Tiemann 2026-08-13 16:08:31 -04:00
parent faafb1a11f
commit e5c0d60d09
5 changed files with 20 additions and 10 deletions

View File

@ -1028,8 +1028,8 @@ class ModuleBayTable(ModularDeviceComponentTable):
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
# __str__() includes the manufacturer, but import resolves by name alone.
transform=lambda obj: obj.name,
# Export as bare name (import resolves by name alone); UI keeps str()'s manufacturer prefix.
export_transform=lambda obj: obj.name,
)
class Meta(ModularDeviceComponentTable.Meta):

View File

@ -305,8 +305,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
# __str__() includes the manufacturer, but import resolves by name alone.
transform=lambda obj: obj.name,
# Export as bare name (import resolves by name alone); UI keeps str()'s manufacturer prefix.
export_transform=lambda obj: obj.name,
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')

View File

@ -78,8 +78,8 @@ class ModuleTypeTable(PrimaryModelTable):
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
# __str__() includes the manufacturer, but import resolves by name alone.
transform=lambda obj: obj.name,
# Export as bare name (import resolves by name alone); UI keeps str()'s manufacturer prefix.
export_transform=lambda obj: obj.name,
)
model = tables.Column(
linkify=True,

View File

@ -516,16 +516,18 @@ class ModuleTypeImportFormTestCase(TestCase):
self.assertFalse(form.save().module_bay_types.exists())
def test_module_bay_types_round_trips_through_the_table_column_export_value(self):
"""The table's CSV export (multi-value separator, name-only transform) must be re-importable."""
"""The table's CSV export (multi-value separator, name-only transform) must be re-importable,
without changing the rendered UI column, which should keep str()'s manufacturer prefix."""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
bay_type_a = ModuleBayType.objects.create(name='SFP28', slug='sfp28', manufacturer=manufacturer)
bay_type_b = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28', manufacturer=manufacturer)
original = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
original.module_bay_types.set([bay_type_a, bay_type_b])
table = ModuleTypeTable([original])
exported_value = table.columns['module_bay_types'].column.value(original.module_bay_types.all())
column = ModuleTypeTable([original]).columns['module_bay_types'].column
exported_value = column.value(original.module_bay_types.all())
self.assertEqual(exported_value, 'QSFP28, SFP28')
self.assertIn(str(manufacturer), str(column.render(original.module_bay_types.all())))
form = ModuleTypeImportForm({
'manufacturer': manufacturer.name,

View File

@ -131,9 +131,17 @@ class DurationColumn(tables.Column):
class ManyToManyColumn(tables.ManyToManyColumn):
"""
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
export_transform: optional callable used only for value() (CSV/table export), letting export use a
different representation than the rendered column (e.g. a bare name where the UI shows str(obj)).
Defaults to transform, matching the stock behavior of exporting the same text that's rendered.
"""
def __init__(self, *args, export_transform=None, **kwargs):
super().__init__(*args, **kwargs)
self.export_transform = export_transform or self.transform
def value(self, value):
items = [self.transform(item) for item in self.filter(value)]
items = [self.export_transform(item) for item in self.filter(value)]
return self.separator.join(items)