Kaynağa Gözat

Merge pull request #6579 from netbox-community/6068-csv-export

Closes #6068: Clean up CSV export
Jeremy Stretch 4 yıl önce
ebeveyn
işleme
eda1c6b2c6

+ 1 - 1
docs/development/extending-models.md

@@ -34,7 +34,7 @@ class Foo(models.Model):
 
 ## 3. Add CSV helpers
 
-Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format.
+Add the name of the new field to `csv_headers`. This will be used when exporting objects in CSV format.
 
 ## 4. Update relevant querysets
 

+ 5 - 0
docs/release-notes/version-3.0.md

@@ -2,6 +2,10 @@
 
 ## v3.0-beta1 (FUTURE)
 
+### Breaking Changes
+
+* The default CSV export format for all objects now includes all available data. Additionally, the CSV headers now use human-friendly titles rather than the raw field names.
+
 ### New Features
 
 #### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963))
@@ -39,6 +43,7 @@ CustomValidator can also be subclassed to enforce more complex logic by overridi
 
 * [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
 * [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
+* [#6068](https://github.com/netbox-community/netbox/issues/6068) - Drop support for legacy static CSV export
 * [#6338](https://github.com/netbox-community/netbox/issues/6338) - Decimal fields are no longer coerced to strings in REST API
 
 ### REST API Changes

+ 4 - 2
netbox/circuits/forms.py

@@ -60,7 +60,9 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Provider
-        fields = Provider.csv_headers
+        fields = (
+            'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+        )
 
 
 class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -234,7 +236,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = CircuitType
-        fields = CircuitType.csv_headers
+        fields = ('name', 'slug', 'description')
         help_texts = {
             'name': 'Name of circuit type',
         }

+ 0 - 52
netbox/circuits/models.py

@@ -63,9 +63,6 @@ class Provider(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
-    ]
     clone_fields = [
         'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
     ]
@@ -79,18 +76,6 @@ class Provider(PrimaryModel):
     def get_absolute_url(self):
         return reverse('circuits:provider', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.asn,
-            self.account,
-            self.portal_url,
-            self.noc_contact,
-            self.admin_contact,
-            self.comments,
-        )
-
 
 #
 # Provider networks
@@ -118,10 +103,6 @@ class ProviderNetwork(PrimaryModel):
         blank=True
     )
 
-    csv_headers = [
-        'provider', 'name', 'description', 'comments',
-    ]
-
     objects = RestrictedQuerySet.as_manager()
 
     class Meta:
@@ -140,14 +121,6 @@ class ProviderNetwork(PrimaryModel):
     def get_absolute_url(self):
         return reverse('circuits:providernetwork', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.provider.name,
-            self.name,
-            self.description,
-            self.comments,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class CircuitType(OrganizationalModel):
@@ -170,8 +143,6 @@ class CircuitType(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -181,13 +152,6 @@ class CircuitType(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('circuits:circuittype', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Circuit(PrimaryModel):
@@ -259,9 +223,6 @@ class Circuit(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
-    ]
     clone_fields = [
         'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
     ]
@@ -276,19 +237,6 @@ class Circuit(PrimaryModel):
     def get_absolute_url(self):
         return reverse('circuits:circuit', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.cid,
-            self.provider.name,
-            self.type.name,
-            self.get_status_display(),
-            self.tenant.name if self.tenant else None,
-            self.install_date,
-            self.commit_rate,
-            self.description,
-            self.comments,
-        )
-
     def get_status_class(self):
         return CircuitStatusChoices.CSS_CLASSES.get(self.status)
 

+ 40 - 21
netbox/dcim/forms.py

@@ -209,7 +209,7 @@ class RegionCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Region
-        fields = Region.csv_headers
+        fields = ('name', 'slug', 'parent', 'description')
 
 
 class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -262,7 +262,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = SiteGroup
-        fields = SiteGroup.csv_headers
+        fields = ('name', 'slug', 'parent', 'description')
 
 
 class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -383,7 +383,11 @@ class SiteCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Site
-        fields = Site.csv_headers
+        fields = (
+            'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'contact_email', 'comments',
+        )
         help_texts = {
             'time_zone': mark_safe(
                 'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
@@ -522,7 +526,7 @@ class LocationCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Location
-        fields = Location.csv_headers
+        fields = ('site', 'parent', 'name', 'slug', 'description')
 
 
 class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -595,7 +599,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = RackRole
-        fields = RackRole.csv_headers
+        fields = ('name', 'slug', 'color', 'description')
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
         }
@@ -728,7 +732,10 @@ class RackCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Rack
-        fields = Rack.csv_headers
+        fields = (
+            'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
+            'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+        )
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
@@ -1114,7 +1121,7 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Manufacturer
-        fields = Manufacturer.csv_headers
+        fields = ('name', 'slug', 'description')
 
 
 class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1923,7 +1930,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = DeviceRole
-        fields = DeviceRole.csv_headers
+        fields = ('name', 'slug', 'color', 'vm_role', 'description')
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
         }
@@ -1987,7 +1994,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Platform
-        fields = Platform.csv_headers
+        fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
 
 
 class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -2676,7 +2683,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = ConsolePort
-        fields = ConsolePort.csv_headers
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
 #
@@ -2783,7 +2790,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = ConsoleServerPort
-        fields = ConsoleServerPort.csv_headers
+        fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
 
 
 #
@@ -2886,7 +2893,9 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = PowerPort
-        fields = PowerPort.csv_headers
+        fields = (
+            'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
+        )
 
 
 #
@@ -3036,7 +3045,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = PowerOutlet
-        fields = PowerOutlet.csv_headers
+        fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -3376,7 +3385,10 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Interface
-        fields = Interface.csv_headers
+        fields = (
+            'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
+            'mgmt_only', 'description', 'mode',
+        )
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -3559,7 +3571,9 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = FrontPort
-        fields = FrontPort.csv_headers
+        fields = (
+            'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
+        )
         help_texts = {
             'rear_port_position': 'Mapped position on corresponding rear port',
         }
@@ -3675,7 +3689,7 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = RearPort
-        fields = RearPort.csv_headers
+        fields = ('device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description')
         help_texts = {
             'positions': 'Number of front ports which may be mapped'
         }
@@ -3774,7 +3788,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = DeviceBay
-        fields = DeviceBay.csv_headers
+        fields = ('device', 'name', 'label', 'installed_device', 'description')
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -3880,7 +3894,9 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = InventoryItem
-        fields = InventoryItem.csv_headers
+        fields = (
+            'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+        )
 
 
 class InventoryItemBulkCreateForm(
@@ -4763,7 +4779,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = VirtualChassis
-        fields = VirtualChassis.csv_headers
+        fields = ('name', 'domain', 'master')
 
 
 class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
@@ -4857,7 +4873,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = PowerPanel
-        fields = PowerPanel.csv_headers
+        fields = ('site', 'location', 'name')
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
@@ -5054,7 +5070,10 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = PowerFeed
-        fields = PowerFeed.csv_headers
+        fields = (
+            'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
+            'voltage', 'amperage', 'max_utilization', 'comments',
+        )
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)

+ 0 - 19
netbox/dcim/models/cables.py

@@ -111,11 +111,6 @@ class Cable(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
-        'color', 'length', 'length_unit',
-    ]
-
     class Meta:
         ordering = ['pk']
         unique_together = (
@@ -289,20 +284,6 @@ class Cable(PrimaryModel):
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         self._pk = self.pk
 
-    def to_csv(self):
-        return (
-            '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
-            self.termination_a_id,
-            '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
-            self.termination_b_id,
-            self.get_type_display(),
-            self.get_status_display(),
-            self.label,
-            self.color,
-            self.length,
-            self.length_unit,
-        )
-
     def get_status_class(self):
         return CableStatusChoices.CSS_CLASSES.get(self.status)
 

+ 0 - 135
netbox/dcim/models/device_components.py

@@ -229,8 +229,6 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
         help_text='Port speed in bits per second'
     )
 
-    csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -238,17 +236,6 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
     def get_absolute_url(self):
         return reverse('dcim:consoleport', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.type,
-            self.speed,
-            self.mark_connected,
-            self.description,
-        )
-
 
 #
 # Console server ports
@@ -272,8 +259,6 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
         help_text='Port speed in bits per second'
     )
 
-    csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -281,17 +266,6 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
     def get_absolute_url(self):
         return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.type,
-            self.speed,
-            self.mark_connected,
-            self.description,
-        )
-
 
 #
 # Power ports
@@ -321,10 +295,6 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
         help_text="Allocated power draw (watts)"
     )
 
-    csv_headers = [
-        'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
-    ]
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -332,18 +302,6 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
     def get_absolute_url(self):
         return reverse('dcim:powerport', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.get_type_display(),
-            self.mark_connected,
-            self.maximum_draw,
-            self.allocated_draw,
-            self.description,
-        )
-
     def clean(self):
         super().clean()
 
@@ -433,8 +391,6 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
         help_text="Phase (for three-phase feeds)"
     )
 
-    csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -442,18 +398,6 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
     def get_absolute_url(self):
         return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.get_type_display(),
-            self.mark_connected,
-            self.power_port.name if self.power_port else None,
-            self.get_feed_leg_display(),
-            self.description,
-        )
-
     def clean(self):
         super().clean()
 
@@ -570,11 +514,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
         related_query_name='interface'
     )
 
-    csv_headers = [
-        'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
-        'mgmt_only', 'description', 'mode',
-    ]
-
     class Meta:
         ordering = ('device', CollateAsChar('_name'))
         unique_together = ('device', 'name')
@@ -582,23 +521,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     def get_absolute_url(self):
         return reverse('dcim:interface', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier if self.device else None,
-            self.name,
-            self.label,
-            self.parent.name if self.parent else None,
-            self.lag.name if self.lag else None,
-            self.get_type_display(),
-            self.enabled,
-            self.mark_connected,
-            self.mac_address,
-            self.mtu,
-            self.mgmt_only,
-            self.description,
-            self.get_mode_display(),
-        )
-
     def clean(self):
         super().clean()
 
@@ -705,10 +627,6 @@ class FrontPort(ComponentModel, CableTermination):
         ]
     )
 
-    csv_headers = [
-        'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
-    ]
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = (
@@ -719,18 +637,6 @@ class FrontPort(ComponentModel, CableTermination):
     def get_absolute_url(self):
         return reverse('dcim:frontport', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.get_type_display(),
-            self.mark_connected,
-            self.rear_port.name,
-            self.rear_port_position,
-            self.description,
-        )
-
     def clean(self):
         super().clean()
 
@@ -765,8 +671,6 @@ class RearPort(ComponentModel, CableTermination):
         ]
     )
 
-    csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -785,17 +689,6 @@ class RearPort(ComponentModel, CableTermination):
                              f"({frontport_count})"
             })
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.get_type_display(),
-            self.mark_connected,
-            self.positions,
-            self.description,
-        )
-
 
 #
 # Device bays
@@ -814,8 +707,6 @@ class DeviceBay(ComponentModel):
         null=True
     )
 
-    csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
-
     class Meta:
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
@@ -823,15 +714,6 @@ class DeviceBay(ComponentModel):
     def get_absolute_url(self):
         return reverse('dcim:devicebay', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.label,
-            self.installed_device.identifier if self.installed_device else None,
-            self.description,
-        )
-
     def clean(self):
         super().clean()
 
@@ -907,26 +789,9 @@ class InventoryItem(MPTTModel, ComponentModel):
 
     objects = TreeManager()
 
-    csv_headers = [
-        'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
-    ]
-
     class Meta:
         ordering = ('device__id', 'parent__id', '_name')
         unique_together = ('device', 'parent', 'name')
 
     def get_absolute_url(self):
         return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
-
-    def to_csv(self):
-        return (
-            self.device.name or '{{{}}}'.format(self.device.pk),
-            self.name,
-            self.label,
-            self.manufacturer.name if self.manufacturer else None,
-            self.part_id,
-            self.serial,
-            self.asset_tag,
-            self.discovered,
-            self.description,
-        )

+ 0 - 64
netbox/dcim/models/devices.py

@@ -56,8 +56,6 @@ class Manufacturer(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -67,13 +65,6 @@ class Manufacturer(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('dcim:manufacturer', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.description
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceType(PrimaryModel):
@@ -379,8 +370,6 @@ class DeviceRole(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -390,15 +379,6 @@ class DeviceRole(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('dcim:devicerole', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.color,
-            self.vm_role,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Platform(OrganizationalModel):
@@ -442,8 +422,6 @@ class Platform(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -453,16 +431,6 @@ class Platform(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('dcim:platform', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.manufacturer.name if self.manufacturer else None,
-            self.napalm_driver,
-            self.napalm_args,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Device(PrimaryModel, ConfigContextModel):
@@ -611,10 +579,6 @@ class Device(PrimaryModel, ConfigContextModel):
 
     objects = ConfigContextModelQuerySet.as_manager()
 
-    csv_headers = [
-        'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-        'site', 'location', 'rack_name', 'position', 'face', 'comments',
-    ]
     clone_fields = [
         'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster',
     ]
@@ -816,25 +780,6 @@ class Device(PrimaryModel, ConfigContextModel):
             device.rack = self.rack
             device.save()
 
-    def to_csv(self):
-        return (
-            self.name or '',
-            self.device_role.name,
-            self.tenant.name if self.tenant else None,
-            self.device_type.manufacturer.name,
-            self.device_type.model,
-            self.platform.name if self.platform else None,
-            self.serial,
-            self.asset_tag,
-            self.get_status_display(),
-            self.site.name,
-            self.rack.location.name if self.rack and self.rack.location else None,
-            self.rack.name if self.rack else None,
-            self.position,
-            self.get_face_display(),
-            self.comments,
-        )
-
     @property
     def identifier(self):
         """
@@ -929,8 +874,6 @@ class VirtualChassis(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'domain', 'master']
-
     class Meta:
         ordering = ['name']
         verbose_name_plural = 'virtual chassis'
@@ -967,10 +910,3 @@ class VirtualChassis(PrimaryModel):
             )
 
         return super().delete(*args, **kwargs)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.domain,
-            self.master.name if self.master else None,
-        )

+ 0 - 31
netbox/dcim/models/power.py

@@ -42,8 +42,6 @@ class PowerPanel(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['site', 'location', 'name']
-
     class Meta:
         ordering = ['site', 'name']
         unique_together = ['site', 'name']
@@ -54,13 +52,6 @@ class PowerPanel(PrimaryModel):
     def get_absolute_url(self):
         return reverse('dcim:powerpanel', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.site.name,
-            self.location.name if self.location else None,
-            self.name,
-        )
-
     def clean(self):
         super().clean()
 
@@ -133,10 +124,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
-        'voltage', 'amperage', 'max_utilization', 'comments',
-    ]
     clone_fields = [
         'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
         'max_utilization', 'available_power',
@@ -152,24 +139,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
     def get_absolute_url(self):
         return reverse('dcim:powerfeed', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.power_panel.site.name,
-            self.power_panel.name,
-            self.rack.location.name if self.rack and self.rack.location else None,
-            self.rack.name if self.rack else None,
-            self.name,
-            self.get_status_display(),
-            self.get_type_display(),
-            self.mark_connected,
-            self.get_supply_display(),
-            self.get_phase_display(),
-            self.voltage,
-            self.amperage,
-            self.max_utilization,
-            self.comments,
-        )
-
     def clean(self):
         super().clean()
 

+ 0 - 48
netbox/dcim/models/racks.py

@@ -58,8 +58,6 @@ class RackRole(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'color', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -69,14 +67,6 @@ class RackRole(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('dcim:rackrole', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.color,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Rack(PrimaryModel):
@@ -191,10 +181,6 @@ class Rack(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
-        'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
-    ]
     clone_fields = [
         'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
         'outer_depth', 'outer_unit',
@@ -251,27 +237,6 @@ class Rack(PrimaryModel):
                         'location': f"Location must be from the same site, {self.site}."
                     })
 
-    def to_csv(self):
-        return (
-            self.site.name,
-            self.location.name if self.location else None,
-            self.name,
-            self.facility_id,
-            self.tenant.name if self.tenant else None,
-            self.get_status_display(),
-            self.role.name if self.role else None,
-            self.get_type_display() if self.type else None,
-            self.serial,
-            self.asset_tag,
-            self.width,
-            self.u_height,
-            self.desc_units,
-            self.outer_width,
-            self.outer_depth,
-            self.outer_unit,
-            self.comments,
-        )
-
     @property
     def units(self):
         if self.desc_units:
@@ -493,8 +458,6 @@ class RackReservation(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description']
-
     class Meta:
         ordering = ['created', 'pk']
 
@@ -531,17 +494,6 @@ class RackReservation(PrimaryModel):
                     )
                 })
 
-    def to_csv(self):
-        return (
-            self.rack.site.name,
-            self.rack.location if self.rack.location else None,
-            self.rack.name,
-            ','.join([str(u) for u in self.units]),
-            self.tenant.name if self.tenant else None,
-            self.user.username,
-            self.description
-        )
-
     @property
     def unit_list(self):
         return array_to_string(self.units)

+ 0 - 57
netbox/dcim/models/sites.py

@@ -54,19 +54,9 @@ class Region(NestedGroupModel):
         blank=True
     )
 
-    csv_headers = ['name', 'slug', 'parent', 'description']
-
     def get_absolute_url(self):
         return reverse('dcim:region', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.parent.name if self.parent else None,
-            self.description,
-        )
-
     def get_site_count(self):
         return Site.objects.filter(
             Q(region=self) |
@@ -106,19 +96,9 @@ class SiteGroup(NestedGroupModel):
         blank=True
     )
 
-    csv_headers = ['name', 'slug', 'parent', 'description']
-
     def get_absolute_url(self):
         return reverse('dcim:sitegroup', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.parent.name if self.parent else None,
-            self.description,
-        )
-
     def get_site_count(self):
         return Site.objects.filter(
             Q(group=self) |
@@ -236,11 +216,6 @@ class Site(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-        'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-        'contact_email', 'comments',
-    ]
     clone_fields = [
         'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
@@ -255,28 +230,6 @@ class Site(PrimaryModel):
     def get_absolute_url(self):
         return reverse('dcim:site', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.get_status_display(),
-            self.region.name if self.region else None,
-            self.group.name if self.group else None,
-            self.tenant.name if self.tenant else None,
-            self.facility,
-            self.asn,
-            self.time_zone,
-            self.description,
-            self.physical_address,
-            self.shipping_address,
-            self.latitude,
-            self.longitude,
-            self.contact_name,
-            self.contact_phone,
-            self.contact_email,
-            self.comments,
-        )
-
     def get_status_class(self):
         return SiteStatusChoices.CSS_CLASSES.get(self.status)
 
@@ -318,7 +271,6 @@ class Location(NestedGroupModel):
         to='extras.ImageAttachment'
     )
 
-    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
     clone_fields = ['site', 'parent', 'description']
 
     class Meta:
@@ -331,15 +283,6 @@ class Location(NestedGroupModel):
     def get_absolute_url(self):
         return reverse('dcim:location', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.site,
-            self.parent.name if self.parent else '',
-            self.name,
-            self.slug,
-            self.description,
-        )
-
     def clean(self):
         super().clean()
 

+ 7 - 2
netbox/dcim/tests/test_views.py

@@ -580,11 +580,11 @@ device-bays:
         db1 = DeviceBayTemplate.objects.first()
         self.assertEqual(db1.name, 'Device Bay 1')
 
-    def test_devicetype_export(self):
-
+    def test_export_objects(self):
         url = reverse('dcim:devicetype_list')
         self.add_permissions('dcim.view_devicetype')
 
+        # Test default YAML export
         response = self.client.get('{}?export'.format(url))
         self.assertEqual(response.status_code, 200)
         data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
@@ -592,6 +592,11 @@ device-bays:
         self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1')
         self.assertEqual(data[0]['model'], 'Device Type 1')
 
+        # Test table-based export
+        response = self.client.get(f'{url}?export=table')
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
+
 
 #
 # DeviceType components

+ 0 - 53
netbox/dcim/views.py

@@ -2530,23 +2530,6 @@ class ConsoleConnectionsListView(generic.ObjectListView):
     table = tables.ConsoleConnectionTable
     template_name = 'dcim/connections_list.html'
 
-    def queryset_to_csv(self):
-        csv_data = [
-            # Headers
-            ','.join(['console_server', 'port', 'device', 'console_port', 'reachable'])
-        ]
-        for obj in self.queryset:
-            csv = csv_format([
-                obj._path.destination.device.identifier if obj._path.destination else None,
-                obj._path.destination.name if obj._path.destination else None,
-                obj.device.identifier,
-                obj.name,
-                obj._path.is_active
-            ])
-            csv_data.append(csv)
-
-        return '\n'.join(csv_data)
-
     def extra_context(self):
         return {
             'title': 'Console Connections'
@@ -2560,23 +2543,6 @@ class PowerConnectionsListView(generic.ObjectListView):
     table = tables.PowerConnectionTable
     template_name = 'dcim/connections_list.html'
 
-    def queryset_to_csv(self):
-        csv_data = [
-            # Headers
-            ','.join(['pdu', 'outlet', 'device', 'power_port', 'reachable'])
-        ]
-        for obj in self.queryset:
-            csv = csv_format([
-                obj._path.destination.device.identifier if obj._path.destination else None,
-                obj._path.destination.name if obj._path.destination else None,
-                obj.device.identifier,
-                obj.name,
-                obj._path.is_active
-            ])
-            csv_data.append(csv)
-
-        return '\n'.join(csv_data)
-
     def extra_context(self):
         return {
             'title': 'Power Connections'
@@ -2594,25 +2560,6 @@ class InterfaceConnectionsListView(generic.ObjectListView):
     table = tables.InterfaceConnectionTable
     template_name = 'dcim/connections_list.html'
 
-    def queryset_to_csv(self):
-        csv_data = [
-            # Headers
-            ','.join([
-                'device_a', 'interface_a', 'device_b', 'interface_b', 'reachable'
-            ])
-        ]
-        for obj in self.queryset:
-            csv = csv_format([
-                obj._path.destination.device.identifier if obj._path.destination else None,
-                obj._path.destination.name if obj._path.destination else None,
-                obj.device.identifier,
-                obj.name,
-                obj._path.is_active
-            ])
-            csv_data.append(csv)
-
-        return '\n'.join(csv_data)
-
     def extra_context(self):
         return {
             'title': 'Interface Connections'

+ 1 - 1
netbox/extras/forms.py

@@ -154,7 +154,7 @@ class TagCSVForm(CSVModelForm):
 
     class Meta:
         model = Tag
-        fields = Tag.csv_headers
+        fields = ('name', 'slug', 'color', 'description')
         help_texts = {
             'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
         }

+ 0 - 21
netbox/extras/models/change_logging.py

@@ -80,11 +80,6 @@ class ObjectChange(BigIDModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
-        'related_object_type', 'related_object_id', 'object_repr', 'prechange_data', 'postchange_data',
-    ]
-
     class Meta:
         ordering = ['-time']
 
@@ -109,21 +104,5 @@ class ObjectChange(BigIDModel):
     def get_absolute_url(self):
         return reverse('extras:objectchange', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.time,
-            self.user,
-            self.user_name,
-            self.request_id,
-            self.get_action_display(),
-            self.changed_object_type,
-            self.changed_object_id,
-            self.related_object_type,
-            self.related_object_id,
-            self.object_repr,
-            self.prechange_data,
-            self.postchange_data,
-        )
-
     def get_action_class(self):
         return ObjectChangeActionChoices.CSS_CLASSES.get(self.action)

+ 0 - 10
netbox/extras/models/tags.py

@@ -26,8 +26,6 @@ class Tag(ChangeLoggedModel, TagBase):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'color', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -41,14 +39,6 @@ class Tag(ChangeLoggedModel, TagBase):
             slug += "_%d" % i
         return slug
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.color,
-            self.description
-        )
-
 
 class TaggedItem(BigIDModel, GenericTaggedItemBase):
     tag = models.ForeignKey(

+ 12 - 9
netbox/ipam/forms.py

@@ -76,7 +76,7 @@ class VRFCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = VRF
-        fields = VRF.csv_headers
+        fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description')
 
 
 class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -152,7 +152,7 @@ class RouteTargetCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = RouteTarget
-        fields = RouteTarget.csv_headers
+        fields = ('name', 'description', 'tenant')
 
 
 class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -214,7 +214,7 @@ class RIRCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = RIR
-        fields = RIR.csv_headers
+        fields = ('name', 'slug', 'is_private', 'description')
         help_texts = {
             'name': 'RIR name',
         }
@@ -295,7 +295,7 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Aggregate
-        fields = Aggregate.csv_headers
+        fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
 
 
 class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -369,7 +369,7 @@ class RoleCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Role
-        fields = Role.csv_headers
+        fields = ('name', 'slug', 'weight', 'description')
 
 
 class RoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -517,7 +517,10 @@ class PrefixCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Prefix
-        fields = Prefix.csv_headers
+        fields = (
+            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
+            'description',
+        )
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
@@ -1265,7 +1268,7 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = VLANGroup
-        fields = VLANGroup.csv_headers
+        fields = ('name', 'slug', 'scope_type', 'scope_id', 'description')
 
 
 class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1439,7 +1442,7 @@ class VLANCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = VLAN
-        fields = VLAN.csv_headers
+        fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description')
         help_texts = {
             'vid': 'Numeric VLAN ID (1-4095)',
             'name': 'VLAN name',
@@ -1630,7 +1633,7 @@ class ServiceCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Service
-        fields = Service.csv_headers
+        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
 
 
 class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

+ 0 - 79
netbox/ipam/models/ip.py

@@ -55,8 +55,6 @@ class RIR(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'is_private', 'description']
-
     class Meta:
         ordering = ['name']
         verbose_name = 'RIR'
@@ -68,14 +66,6 @@ class RIR(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('ipam:rir', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.is_private,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Aggregate(PrimaryModel):
@@ -108,7 +98,6 @@ class Aggregate(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['prefix', 'rir', 'tenant', 'date_added', 'description']
     clone_fields = [
         'rir', 'tenant', 'date_added', 'description',
     ]
@@ -160,15 +149,6 @@ class Aggregate(PrimaryModel):
                     )
                 })
 
-    def to_csv(self):
-        return (
-            self.prefix,
-            self.rir.name,
-            self.tenant.name if self.tenant else None,
-            self.date_added,
-            self.description,
-        )
-
     @property
     def family(self):
         if self.prefix:
@@ -208,8 +188,6 @@ class Role(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'weight', 'description']
-
     class Meta:
         ordering = ['weight', 'name']
 
@@ -219,14 +197,6 @@ class Role(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('ipam:role', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.weight,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Prefix(PrimaryModel):
@@ -309,10 +279,6 @@ class Prefix(PrimaryModel):
 
     objects = PrefixQuerySet.as_manager()
 
-    csv_headers = [
-        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
-        'description',
-    ]
     clone_fields = [
         'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
     ]
@@ -375,21 +341,6 @@ class Prefix(PrimaryModel):
 
         super().save(*args, **kwargs)
 
-    def to_csv(self):
-        return (
-            self.prefix,
-            self.vrf.name if self.vrf else None,
-            self.tenant.name if self.tenant else None,
-            self.site.name if self.site else None,
-            self.vlan.group.name if self.vlan and self.vlan.group else None,
-            self.vlan.vid if self.vlan else None,
-            self.get_status_display(),
-            self.role.name if self.role else None,
-            self.is_pool,
-            self.mark_utilized,
-            self.description,
-        )
-
     @property
     def family(self):
         if self.prefix:
@@ -615,10 +566,6 @@ class IPAddress(PrimaryModel):
 
     objects = IPAddressManager()
 
-    csv_headers = [
-        'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary',
-        'dns_name', 'description',
-    ]
     clone_fields = [
         'vrf', 'tenant', 'status', 'role', 'description',
     ]
@@ -697,32 +644,6 @@ class IPAddress(PrimaryModel):
         # Annotate the assigned object, if any
         return super().to_objectchange(action, related_object=self.assigned_object)
 
-    def to_csv(self):
-
-        # Determine if this IP is primary for a Device
-        is_primary = False
-        if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
-            is_primary = True
-        elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
-            is_primary = True
-
-        obj_type = None
-        if self.assigned_object_type:
-            obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}'
-
-        return (
-            self.address,
-            self.vrf.name if self.vrf else None,
-            self.tenant.name if self.tenant else None,
-            self.get_status_display(),
-            self.get_role_display(),
-            obj_type,
-            self.assigned_object_id,
-            is_primary,
-            self.dns_name,
-            self.description,
-        )
-
     @property
     def family(self):
         if self.address:

+ 0 - 12
netbox/ipam/models/services.py

@@ -67,8 +67,6 @@ class Service(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description']
-
     class Meta:
         ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
 
@@ -91,16 +89,6 @@ class Service(PrimaryModel):
         if not self.device and not self.virtual_machine:
             raise ValidationError("A service must be associated with either a device or a virtual machine.")
 
-    def to_csv(self):
-        return (
-            self.device.name if self.device else None,
-            self.virtual_machine.name if self.virtual_machine else None,
-            self.name,
-            self.get_protocol_display(),
-            self.ports,
-            self.description,
-        )
-
     @property
     def port_list(self):
         return array_to_string(self.ports)

+ 0 - 24
netbox/ipam/models/vlans.py

@@ -54,8 +54,6 @@ class VLANGroup(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description']
-
     class Meta:
         ordering = ('name', 'pk')  # Name may be non-unique
         unique_together = [
@@ -80,15 +78,6 @@ class VLANGroup(OrganizationalModel):
         if self.scope_id and not self.scope_type:
             raise ValidationError("Cannot set scope_id without scope_type.")
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            f'{self.scope_type.app_label}.{self.scope_type.model}',
-            self.scope_id,
-            self.description,
-        )
-
     def get_next_available_vid(self):
         """
         Return the first available VLAN ID (1-4094) in the group.
@@ -157,7 +146,6 @@ class VLAN(PrimaryModel):
 
     objects = VLANQuerySet.as_manager()
 
-    csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
     clone_fields = [
         'site', 'group', 'tenant', 'status', 'role', 'description',
     ]
@@ -187,18 +175,6 @@ class VLAN(PrimaryModel):
                          f"site {self.site}."
             })
 
-    def to_csv(self):
-        return (
-            self.site.name if self.site else None,
-            self.group.name if self.group else None,
-            self.vid,
-            self.name,
-            self.tenant.name if self.tenant else None,
-            self.get_status_display(),
-            self.role.name if self.role else None,
-            self.description,
-        )
-
     def get_status_class(self):
         return VLANStatusChoices.CSS_CLASSES.get(self.status)
 

+ 0 - 19
netbox/ipam/models/vrfs.py

@@ -60,7 +60,6 @@ class VRF(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     clone_fields = [
         'tenant', 'enforce_unique', 'description',
     ]
@@ -78,15 +77,6 @@ class VRF(PrimaryModel):
     def get_absolute_url(self):
         return reverse('ipam:vrf', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.rd,
-            self.tenant.name if self.tenant else None,
-            self.enforce_unique,
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class RouteTarget(PrimaryModel):
@@ -112,8 +102,6 @@ class RouteTarget(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'description', 'tenant']
-
     class Meta:
         ordering = ['name']
 
@@ -122,10 +110,3 @@ class RouteTarget(PrimaryModel):
 
     def get_absolute_url(self):
         return reverse('ipam:routetarget', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.description,
-            self.tenant.name if self.tenant else None,
-        )

+ 35 - 58
netbox/netbox/views/generic.py

@@ -16,7 +16,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2.export import TableExport
 
-from extras.models import CustomField, ExportTemplate
+from extras.models import ExportTemplate
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction
 from utilities.forms import (
@@ -24,7 +24,7 @@ from utilities.forms import (
 )
 from utilities.permissions import get_permission_for_model
 from utilities.tables import paginate_table
-from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
+from utilities.utils import normalize_querydict, prepare_cloned_fields
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 
 
@@ -92,7 +92,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
 
-    def queryset_to_yaml(self):
+    def export_yaml(self):
         """
         Export the queryset of objects as concatenated YAML documents.
         """
@@ -100,34 +100,27 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
 
         return '---\n'.join(yaml_data)
 
-    def queryset_to_csv(self):
+    def export_table(self, table, columns=None):
         """
-        Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
-        """
-        csv_data = []
-        custom_fields = []
-
-        # Start with the column headers
-        headers = self.queryset.model.csv_headers.copy()
-
-        # Add custom field headers, if any
-        if hasattr(self.queryset.model, 'custom_field_data'):
-            for custom_field in CustomField.objects.get_for_model(self.queryset.model):
-                headers.append(custom_field.name)
-                custom_fields.append(custom_field.name)
-
-        csv_data.append(','.join(headers))
+        Export all table data in CSV format.
 
-        # Iterate through the queryset appending each object
-        for obj in self.queryset:
-            data = obj.to_csv()
-
-            for custom_field in custom_fields:
-                data += (obj.cf.get(custom_field, ''),)
-
-            csv_data.append(csv_format(data))
-
-        return '\n'.join(csv_data)
+        :param table: The Table instance to export
+        :param columns: A list of specific columns to include. If not specified, all columns will be exported.
+        """
+        exclude_columns = {'pk'}
+        if columns:
+            all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns]
+            exclude_columns.update({
+                col for col in all_columns if col not in columns
+            })
+        exporter = TableExport(
+            export_format=TableExport.CSV,
+            table=table,
+            exclude_columns=exclude_columns
+        )
+        return exporter.response(
+            filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
+        )
 
     def get(self, request):
 
@@ -137,7 +130,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         if self.filterset:
             self.queryset = self.filterset(request.GET, self.queryset).qs
 
-        # Check for export rendering (except for table-based)
+        # Compile a dictionary indicating which permissions are available to the current user for this model
+        permissions = {}
+        for action in ('add', 'change', 'delete', 'view'):
+            perm_name = get_permission_for_model(model, action)
+            permissions[action] = request.user.has_perm(perm_name)
+
+        # Export template/YAML rendering
         if 'export' in request.GET and request.GET['export'] != 'table':
 
             # An export template has been specified
@@ -155,44 +154,22 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
 
             # Check for YAML export support
             elif hasattr(model, 'to_yaml'):
-                response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
+                response = HttpResponse(self.export_yaml(), content_type='text/yaml')
                 filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
                 response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
                 return response
 
-            # Fall back to built-in CSV formatting if export requested but no template specified
-            elif 'export' in request.GET and hasattr(model, 'to_csv'):
-                response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
-                filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
-                response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
-                return response
-
-        # Compile a dictionary indicating which permissions are available to the current user for this model
-        permissions = {}
-        for action in ('add', 'change', 'delete', 'view'):
-            perm_name = get_permission_for_model(model, action)
-            permissions[action] = request.user.has_perm(perm_name)
-
         # Construct the objects table
         table = self.table(self.queryset, user=request.user)
         if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
             table.columns.show('pk')
 
-        # Handle table-based export
+        # Handle table-based exports (current view or static CSV-based)
         if request.GET.get('export') == 'table':
-            exclude_columns = {'pk'}
-            exclude_columns.update({
-                name for name, _ in table.available_columns
-            })
-            exporter = TableExport(
-                export_format=TableExport.CSV,
-                table=table,
-                exclude_columns=exclude_columns,
-                dataset_kwargs={},
-            )
-            return exporter.response(
-                filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
-            )
+            columns = [name for name, _ in table.selected_columns]
+            return self.export_table(table, columns)
+        elif 'export' in request.GET:
+            return self.export_table(table)
 
         # Paginate the objects table
         paginate_table(table, request)

+ 2 - 2
netbox/tenancy/forms.py

@@ -41,7 +41,7 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = TenantGroup
-        fields = TenantGroup.csv_headers
+        fields = ('name', 'slug', 'parent', 'description')
 
 
 class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -99,7 +99,7 @@ class TenantCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Tenant
-        fields = Tenant.csv_headers
+        fields = ('name', 'slug', 'group', 'description', 'comments')
 
 
 class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):

+ 0 - 20
netbox/tenancy/models.py

@@ -40,22 +40,12 @@ class TenantGroup(NestedGroupModel):
         blank=True
     )
 
-    csv_headers = ['name', 'slug', 'parent', 'description']
-
     class Meta:
         ordering = ['name']
 
     def get_absolute_url(self):
         return reverse('tenancy:tenantgroup', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.parent.name if self.parent else '',
-            self.description,
-        )
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class Tenant(PrimaryModel):
@@ -88,7 +78,6 @@ class Tenant(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'group', 'description', 'comments']
     clone_fields = [
         'group', 'description',
     ]
@@ -101,12 +90,3 @@ class Tenant(PrimaryModel):
 
     def get_absolute_url(self):
         return reverse('tenancy:tenant', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.group.name if self.group else None,
-            self.description,
-            self.comments,
-        )

+ 1 - 1
netbox/utilities/templates/buttons/export.html

@@ -4,7 +4,7 @@
     </button>
     <ul class="dropdown-menu dropdown-menu-end">
         <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export=table">Current View</a></li>
-        <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">Default Format</a></li>
+        <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">All Data</a></li>
         {% if export_templates %}
         <li>
           <hr class="dropdown-divider">

+ 1 - 4
netbox/utilities/testing/views.py

@@ -454,10 +454,7 @@ class ViewTestCases:
             # Test default CSV export
             response = self.client.get(f'{url}?export')
             self.assertHttpStatus(response, 200)
-            if hasattr(self.model, 'csv_headers'):
-                self.assertEqual(response.get('Content-Type'), 'text/csv')
-                content = response.content.decode('utf-8')
-                self.assertEqual(content.splitlines()[0], ','.join(self.model.csv_headers))
+            self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
 
             # Test table-based export
             response = self.client.get(f'{url}?export=table')

+ 9 - 5
netbox/virtualization/forms.py

@@ -43,7 +43,7 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = ClusterType
-        fields = ClusterType.csv_headers
+        fields = ('name', 'slug', 'description')
 
 
 class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -79,7 +79,7 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = ClusterGroup
-        fields = ClusterGroup.csv_headers
+        fields = ('name', 'slug', 'description')
 
 
 class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -174,7 +174,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = Cluster
-        fields = Cluster.csv_headers
+        fields = ('name', 'type', 'group', 'site', 'comments')
 
 
 class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -468,7 +468,9 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm):
 
     class Meta:
         model = VirtualMachine
-        fields = VirtualMachine.csv_headers
+        fields = (
+            'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        )
 
 
 class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@@ -735,7 +737,9 @@ class VMInterfaceCSVForm(CSVModelForm):
 
     class Meta:
         model = VMInterface
-        fields = VMInterface.csv_headers
+        fields = (
+            'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+        )
 
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data

+ 0 - 62
netbox/virtualization/models.py

@@ -50,8 +50,6 @@ class ClusterType(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -61,13 +59,6 @@ class ClusterType(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('virtualization:clustertype', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.description,
-        )
-
 
 #
 # Cluster groups
@@ -93,8 +84,6 @@ class ClusterGroup(OrganizationalModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'slug', 'description']
-
     class Meta:
         ordering = ['name']
 
@@ -104,13 +93,6 @@ class ClusterGroup(OrganizationalModel):
     def get_absolute_url(self):
         return reverse('virtualization:clustergroup', args=[self.pk])
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.description,
-        )
-
 
 #
 # Clusters
@@ -157,7 +139,6 @@ class Cluster(PrimaryModel):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = ['name', 'type', 'group', 'site', 'comments']
     clone_fields = [
         'type', 'group', 'tenant', 'site',
     ]
@@ -184,16 +165,6 @@ class Cluster(PrimaryModel):
                     )
                 })
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.type.name,
-            self.group.name if self.group else None,
-            self.site.name if self.site else None,
-            self.tenant.name if self.tenant else None,
-            self.comments,
-        )
-
 
 #
 # Virtual machines
@@ -287,9 +258,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
 
     objects = ConfigContextModelQuerySet.as_manager()
 
-    csv_headers = [
-        'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
-    ]
     clone_fields = [
         'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
     ]
@@ -337,20 +305,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
                         field: f"The specified IP address ({ip}) is not assigned to this VM.",
                     })
 
-    def to_csv(self):
-        return (
-            self.name,
-            self.get_status_display(),
-            self.role.name if self.role else None,
-            self.cluster.name,
-            self.tenant.name if self.tenant else None,
-            self.platform.name if self.platform else None,
-            self.vcpus,
-            self.memory,
-            self.disk,
-            self.comments,
-        )
-
     def get_status_class(self):
         return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status)
 
@@ -425,10 +379,6 @@ class VMInterface(PrimaryModel, BaseInterface):
 
     objects = RestrictedQuerySet.as_manager()
 
-    csv_headers = [
-        'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
-    ]
-
     class Meta:
         verbose_name = 'interface'
         ordering = ('virtual_machine', CollateAsChar('_name'))
@@ -440,18 +390,6 @@ class VMInterface(PrimaryModel, BaseInterface):
     def get_absolute_url(self):
         return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
 
-    def to_csv(self):
-        return (
-            self.virtual_machine.name,
-            self.name,
-            self.enabled,
-            self.parent.name if self.parent else None,
-            self.mac_address,
-            self.mtu,
-            self.description,
-            self.get_mode_display(),
-        )
-
     def clean(self):
         super().clean()