diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 9a27b68b6..dd2884939 100644 --- a/docs/development/extending-models.md +++ b/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 diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 9cc272687..08685a2b7 100644 --- a/docs/release-notes/version-3.0.md +++ b/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 diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 5b8b6f1b8..da399453b 100644 --- a/netbox/circuits/forms.py +++ b/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', } diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 699ded7b0..39f38d0b0 100644 --- a/netbox/circuits/models.py +++ b/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) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d6c119eca..ea0d7eeac 100644 --- a/netbox/dcim/forms.py +++ b/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 (available options)' @@ -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. 00ff00)'), } @@ -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. 00ff00)'), } @@ -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) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 8dd0f2f11..c3f8cac3f 100644 --- a/netbox/dcim/models/cables.py +++ b/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) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bd7f4ac55..1729fdaa4 100644 --- a/netbox/dcim/models/device_components.py +++ b/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, - ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e41bc8769..10cd35c13 100644 --- a/netbox/dcim/models/devices.py +++ b/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, - ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 03e77eea9..f81abd328 100644 --- a/netbox/dcim/models/power.py +++ b/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() diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 5270004d1..3370badc3 100644 --- a/netbox/dcim/models/racks.py +++ b/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) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 7ab37567a..943e98106 100644 --- a/netbox/dcim/models/sites.py +++ b/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() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 5da1fcb5b..bbb153ee5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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 diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 182e1ab81..93e7a84a6 100644 --- a/netbox/dcim/views.py +++ b/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' diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 47a874705..8acffca6c 100644 --- a/netbox/extras/forms.py +++ b/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. 00ff00)'), } diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index fac86b641..15bd3cbd8 100644 --- a/netbox/extras/models/change_logging.py +++ b/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) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 6268751b2..afeeee53d 100644 --- a/netbox/extras/models/tags.py +++ b/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( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8c490233d..cc53dbe34 100644 --- a/netbox/ipam/forms.py +++ b/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): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index edfad625a..d6b274bbb 100644 --- a/netbox/ipam/models/ip.py +++ b/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: diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 3fc9c8ec6..9efe7fed7 100644 --- a/netbox/ipam/models/services.py +++ b/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) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 9c7a80ade..4ba8d7041 100644 --- a/netbox/ipam/models/vlans.py +++ b/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) diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index c2cd1dc4a..c8e703520 100644 --- a/netbox/ipam/models/vrfs.py +++ b/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, - ) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index f6f153d71..23b7f9c8d 100644 --- a/netbox/netbox/views/generic.py +++ b/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. + Export all table data in CSV format. + + :param table: The Table instance to export + :param columns: A list of specific columns to include. If not specified, all columns will be exported. """ - 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)) - - # 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) + 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) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index d53748055..992964de1 100644 --- a/netbox/tenancy/forms.py +++ b/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): diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 63f960b0e..10bd8c7b0 100644 --- a/netbox/tenancy/models.py +++ b/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, - ) diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index 4664d5e55..c13f0cfdf 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -4,7 +4,7 @@