浏览代码

Closes #23472: Add a plugin API for registering export formats

Introduce a registry of exporters for object list views. Plugins register
Exporter or TableExporter subclasses with register_exporter(), and the
export_format query parameter selects one. NetBox's CSV and YAML exports
become registered exporters, with a single CSV code path for buffered and
streaming responses, so tablib is no longer needed.
Jeremy Stretch 6 天之前
父节点
当前提交
34483c5cf5

+ 0 - 5
base_requirements.txt

@@ -173,11 +173,6 @@ strawberry-graphql-django
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst
 svgwrite
 svgwrite
 
 
-# Tabular dataset library (for table-based exports)
-# Current: https://github.com/jazzband/tablib/releases
-# Previous: https://github.com/jazzband/tablib/blob/master/HISTORY.md
-tablib
-
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # https://github.com/python/tzdata/blob/master/NEWS.md
 # https://github.com/python/tzdata/blob/master/NEWS.md
 tzdata
 tzdata

+ 83 - 0
docs/plugins/development/exporters.md

@@ -0,0 +1,83 @@
+# Exporters
+
+!!! info "This feature was introduced in NetBox v4.8."
+
+The "Export" button on object list views offers a choice of export formats. NetBox provides CSV and (for device types and module types) YAML exporters. Plugins can register their own exporters to add formats, such as XLSX or JSON, by subclassing NetBox's `TableExporter` or `Exporter` class.
+
+A `TableExporter` exports the rows of the list view's table. NetBox resolves the table's columns and passes the exporter a list of column headers and an iterator of cell values for each row. Table exporters are offered under both "Current View" (the user's selected columns) and "All Data" (all columns).
+
+```python title="exporters.py"
+import io
+
+from openpyxl import Workbook
+
+from netbox.exporters import TableExporter, register_exporter
+
+@register_exporter
+class XLSXExporter(TableExporter):
+    slug = 'my_plugin.xlsx'
+    label = 'Excel (XLSX)'
+    content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+    file_extension = 'xlsx'
+
+    def export_rows(self, *, request, headers, rows):
+        workbook = Workbook(write_only=True)
+        sheet = workbook.create_sheet()
+        sheet.append(headers)
+        for row in rows:
+            sheet.append(row)
+        buffer = io.BytesIO()
+        workbook.save(buffer)
+        return buffer.getvalue()
+```
+
+An `Exporter` works on the queryset directly, and is offered under "All Data" only. Override `supports_model()` to limit any exporter to specific models.
+
+```python title="exporters.py"
+from netbox.exporters import Exporter, register_exporter
+
+@register_exporter
+class VendorDeviceTypeExporter(Exporter):
+    slug = 'my_plugin.vendor_device_types'
+    label = 'Vendor Format'
+    content_type = 'application/json'
+    file_extension = 'json'
+
+    def supports_model(self, model):
+        return model._meta.label == 'dcim.DeviceType'
+
+    def export(self, *, request, queryset):
+        ...
+```
+
+The queryset passed to an exporter has the user's filters applied, and is restricted to objects the user has permission to view. `export()` and `export_rows()` return the exported data as a string or bytes, or as an iterable of chunks, which NetBox streams to the client.
+
+The `register_exporter()` decorator registers an exporter with NetBox. Exporters can be defined anywhere within a plugin, but must be imported during plugin initialization, under the PluginConfig's `ready()` method:
+
+```python
+def ready(self):
+    super().ready()
+    from my_plugin import exporters
+```
+
+`slug` must begin with a lowercase letter, and may contain only letters, digits, underscores, and dot-separated segments. A dotted namespace prefix (e.g. `my_plugin.xlsx`) is strongly recommended to avoid collisions with other plugins or with exporters added to NetBox in the future.
+
+!!! warning "Exporters must be stateless"
+    Registration instantiates the class once, and that single instance serves every request. Don't store per-request data on `self`.
+
+## Export Formats
+
+The `export_format` query parameter selects an exporter by its slug:
+
+| URL                                  | Result                                                 |
+|--------------------------------------|--------------------------------------------------------|
+| `?export=table&export_format=<slug>` | Current view, in the given format                      |
+| `?export&export_format=<slug>`       | All data, in the given format                          |
+| `?export=table`                      | Current view, as CSV                                   |
+| `?export`                            | All data, as YAML if the model supports it, or as CSV  |
+
+An unknown slug, or an exporter that doesn't support the model (or the current view), returns a 404 response. Exporters are available only in the web UI. The REST API supports [export templates](../../customization/export-templates.md) only.
+
+::: netbox.exporters.Exporter
+
+::: netbox.exporters.TableExporter

+ 1 - 0
docs/plugins/development/views.md

@@ -154,6 +154,7 @@ Below are the class definitions for NetBox's multi-object views. These views han
     options:
     options:
       members:
       members:
         - get_table
         - get_table
+        - get_export_queryset
         - export_table
         - export_table
         - export_template
         - export_template
 
 

+ 1 - 0
mkdocs.yml

@@ -156,6 +156,7 @@ nav:
             - Search: 'plugins/development/search.md'
             - Search: 'plugins/development/search.md'
             - Event Types: 'plugins/development/event-types.md'
             - Event Types: 'plugins/development/event-types.md'
             - Event Rule Actions: 'plugins/development/event-rule-actions.md'
             - Event Rule Actions: 'plugins/development/event-rule-actions.md'
+            - Exporters: 'plugins/development/exporters.md'
             - Permissions: 'plugins/development/permissions.md'
             - Permissions: 'plugins/development/permissions.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Webhooks: 'plugins/development/webhooks.md'
             - Webhooks: 'plugins/development/webhooks.md'

+ 5 - 0
netbox/core/apps.py

@@ -45,6 +45,7 @@ class CoreConfig(AppConfig):
             check_redis_version,
             check_redis_version,
         )
         )
         from netbox import context_managers  # noqa: F401
         from netbox import context_managers  # noqa: F401
+        from netbox.exporters import CSVExporter, YAMLExporter, register_exporter
         from netbox.models.features import register_models
         from netbox.models.features import register_models
 
 
         # Importing netbox.search.jobs registers its system job(s) (e.g. the
         # Importing netbox.search.jobs registers its system job(s) (e.g. the
@@ -68,6 +69,10 @@ class CoreConfig(AppConfig):
         EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
         EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
         EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
         EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
 
 
+        # Register core exporters
+        register_exporter(CSVExporter)
+        register_exporter(YAMLExporter)
+
         # Clear Redis cache on startup in development mode
         # Clear Redis cache on startup in development mode
         if settings.DEBUG:
         if settings.DEBUG:
             try:
             try:

+ 23 - 4
netbox/dcim/tests/test_views.py

@@ -32,6 +32,7 @@ from netbox.choices import (
     ImportFormatChoices,
     ImportFormatChoices,
     WeightUnitChoices,
     WeightUnitChoices,
 )
 )
+from netbox.exporters import get_exporter
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from users.models import ObjectPermission, Owner, User
 from users.models import ObjectPermission, Owner, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
@@ -1288,13 +1289,14 @@ inventory-items:
         five_bay_device_type = make_device_type('Export Query DT Five Bays', 5)
         five_bay_device_type = make_device_type('Export Query DT Five Bays', 5)
 
 
         view = DeviceTypeListView()
         view = DeviceTypeListView()
+        exporter = get_exporter('yaml')
         view.queryset = DeviceType.objects.filter(pk=one_bay_device_type.pk)
         view.queryset = DeviceType.objects.filter(pk=one_bay_device_type.pk)
         with CaptureQueriesContext(connection) as one_bay_queries:
         with CaptureQueriesContext(connection) as one_bay_queries:
-            view.export_yaml()
+            exporter.export(request=None, queryset=view.get_export_queryset(exporter))
 
 
         view.queryset = DeviceType.objects.filter(pk=five_bay_device_type.pk)
         view.queryset = DeviceType.objects.filter(pk=five_bay_device_type.pk)
         with CaptureQueriesContext(connection) as five_bay_queries:
         with CaptureQueriesContext(connection) as five_bay_queries:
-            view.export_yaml()
+            exporter.export(request=None, queryset=view.get_export_queryset(exporter))
 
 
         self.assertEqual(len(one_bay_queries), len(five_bay_queries))
         self.assertEqual(len(one_bay_queries), len(five_bay_queries))
 
 
@@ -1521,6 +1523,14 @@ console-ports:
         self.assertGreater(len(rows), 1)
         self.assertGreater(len(rows), 1)
         self.assertEqual(len(rows) - 1, DeviceType.objects.count())
         self.assertEqual(len(rows) - 1, DeviceType.objects.count())
 
 
+        # Test CSV export of all data
+        response = self.client.get(f'{url}?export&export_format=csv')
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
+        content = b''.join(response.streaming_content).decode('utf-8')
+        rows = list(csv.reader(StringIO(content)))
+        self.assertEqual(len(rows) - 1, DeviceType.objects.count())
+
 
 
 class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ModuleType
     model = ModuleType
@@ -2177,7 +2187,7 @@ interfaces:
             self.assertEqual(channel.parent, imported_parent)
             self.assertEqual(channel.parent, imported_parent)
 
 
     def test_bulk_yaml_export_prefetches_module_bay_types_on_the_module_type_itself(self):
     def test_bulk_yaml_export_prefetches_module_bay_types_on_the_module_type_itself(self):
-        """Compares an unprefetched to_yaml() call per instance against export_yaml() (which
+        """Compares an unprefetched to_yaml() call per instance against a YAML export (which
         prefetches and issues no queries of its own beyond that), rather than a row-count
         prefetches and issues no queries of its own beyond that), rather than a row-count
         comparison, which other per-instance relations that legitimately scale with it would
         comparison, which other per-instance relations that legitimately scale with it would
         swamp."""
         swamp."""
@@ -2195,9 +2205,10 @@ interfaces:
             [obj.to_yaml() for obj in ModuleType.objects.filter(pk__in=pks)]
             [obj.to_yaml() for obj in ModuleType.objects.filter(pk__in=pks)]
 
 
         view = ModuleTypeListView()
         view = ModuleTypeListView()
+        exporter = get_exporter('yaml')
         view.queryset = ModuleType.objects.filter(pk__in=pks)
         view.queryset = ModuleType.objects.filter(pk__in=pks)
         with CaptureQueriesContext(connection) as prefetched:
         with CaptureQueriesContext(connection) as prefetched:
-            view.export_yaml()
+            exporter.export(request=None, queryset=view.get_export_queryset(exporter))
 
 
         # Without the prefetch, each of the 5 module types issues its own module_bay_types
         # Without the prefetch, each of the 5 module types issues its own module_bay_types
         # query; with it, exactly one query serves all 5.
         # query; with it, exactly one query serves all 5.
@@ -2226,6 +2237,14 @@ interfaces:
         self.assertGreater(len(rows), 1)
         self.assertGreater(len(rows), 1)
         self.assertEqual(len(rows) - 1, ModuleType.objects.count())
         self.assertEqual(len(rows) - 1, ModuleType.objects.count())
 
 
+        # Test CSV export of all data
+        response = self.client.get(f'{url}?export&export_format=csv')
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
+        content = b''.join(response.streaming_content).decode('utf-8')
+        rows = list(csv.reader(StringIO(content)))
+        self.assertEqual(len(rows) - 1, ModuleType.objects.count())
+
     @tag('regression')  # Issue #22961
     @tag('regression')  # Issue #22961
     def test_bulk_edit_module_bay_types(self):
     def test_bulk_edit_module_bay_types(self):
         """
         """

+ 15 - 10
netbox/dcim/views.py

@@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
 from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
 from ipam.tables import VLANTranslationRuleTable
 from ipam.tables import VLANTranslationRuleTable
 from ipam.ui.panels import FHRPGroupAssignmentsPanel
 from ipam.ui.panels import FHRPGroupAssignmentsPanel
+from netbox.exporters import YAMLExporter
 from netbox.object_actions import *
 from netbox.object_actions import *
 from netbox.ui import actions, layout
 from netbox.ui import actions, layout
 from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url, object_view_url
 from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url, object_view_url
@@ -1449,10 +1450,12 @@ class DeviceTypeListView(generic.ObjectListView):
     filterset_form = forms.DeviceTypeFilterForm
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
 
 
-    def export_yaml(self):
-        # Avoid one module_bay_types query per module bay template across the export.
-        self.queryset = self.queryset.prefetch_related('modulebaytemplates__module_bay_types')
-        return super().export_yaml()
+    def get_export_queryset(self, exporter):
+        queryset = super().get_export_queryset(exporter)
+        if isinstance(exporter, YAMLExporter):
+            # Avoid one module_bay_types query per module bay template across the export.
+            queryset = queryset.prefetch_related('modulebaytemplates__module_bay_types')
+        return queryset
 
 
 
 
 @register_model_view(DeviceType)
 @register_model_view(DeviceType)
@@ -1914,12 +1917,14 @@ class ModuleTypeListView(generic.ObjectListView):
     filterset_form = forms.ModuleTypeFilterForm
     filterset_form = forms.ModuleTypeFilterForm
     table = tables.ModuleTypeTable
     table = tables.ModuleTypeTable
 
 
-    def export_yaml(self):
-        # Avoid one module_bay_types query per module type across the export. (Unlike
-        # DeviceType.to_yaml(), ModuleType.to_yaml() doesn't export module bay templates at
-        # all, so there's nothing to prefetch alongside it.)
-        self.queryset = self.queryset.prefetch_related('module_bay_types')
-        return super().export_yaml()
+    def get_export_queryset(self, exporter):
+        queryset = super().get_export_queryset(exporter)
+        if isinstance(exporter, YAMLExporter):
+            # Avoid one module_bay_types query per module type across the export. (Unlike
+            # DeviceType.to_yaml(), ModuleType.to_yaml() doesn't export module bay templates at
+            # all, so there's nothing to prefetch alongside it.)
+            queryset = queryset.prefetch_related('module_bay_types')
+        return queryset
 
 
 
 
 @register_model_view(ModuleType)
 @register_model_view(ModuleType)

+ 160 - 0
netbox/netbox/exporters.py

@@ -0,0 +1,160 @@
+import csv
+import re
+from itertools import chain
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.translation import gettext as _
+
+from netbox.registry import registry
+from utilities.constants import CSV_DELIMITERS
+
+__all__ = (
+    'CSVExporter',
+    'Exporter',
+    'TableExporter',
+    'YAMLExporter',
+    'get_exporter',
+    'get_exporters',
+    'register_exporter',
+)
+
+SLUG_RE = re.compile(r'^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$')
+
+
+class Exporter:
+    """
+    Base class for an export format offered on object list views. Exporters are listed under "All Data" only.
+
+    Registration instantiates the class once, and that instance serves every request, so implementations must be
+    stateless.
+
+    Attributes:
+        slug: A unique identifier for this exporter (e.g. "csv"). Plugins should use a dotted namespace prefix
+            (e.g. "my_plugin.xlsx").
+        label: The human-friendly name shown in the UI
+        content_type: The Content-Type of the HTTP response (e.g. "text/csv; charset=utf-8")
+        file_extension: The extension of the downloaded file, without a leading period (e.g. "csv")
+    """
+    slug = None
+    label = None
+    content_type = None
+    file_extension = None
+
+    def __repr__(self):
+        return f'<{self.__class__.__name__}: {self.slug}>'
+
+    def supports_model(self, model):
+        """
+        Return True if this exporter can export objects of the given model.
+        """
+        return True
+
+    def export(self, *, request, queryset):
+        """
+        Return the exported data as str or bytes, or as an iterable of chunks to stream the response.
+
+        Args:
+            request: The current request
+            queryset: The filtered queryset of objects to export, restricted to those the user can view
+        """
+        raise NotImplementedError(f"{self.__class__.__name__} must implement export()")
+
+
+class TableExporter(Exporter):
+    """
+    An exporter which renders the rows of an object list table. Table exporters are listed under both "Current View"
+    and "All Data".
+    """
+    def export_rows(self, *, request, headers, rows):
+        """
+        Return the exported data (as for export()) given a list of column headers and an iterator of row values.
+
+        Args:
+            request: The current request
+            headers: A list of column headers
+            rows: An iterator yielding a list of cell values for each row
+        """
+        raise NotImplementedError(f"{self.__class__.__name__} must implement export_rows()")
+
+
+class _EchoBuffer:
+    """
+    File-like object whose write() returns the value written, so that csv.writer output can be captured line by line.
+    """
+    def write(self, value):
+        return value
+
+
+class CSVExporter(TableExporter):
+    slug = 'csv'
+    label = 'CSV'
+    content_type = 'text/csv; charset=utf-8'
+    file_extension = 'csv'
+
+    def export_rows(self, *, request, headers, rows):
+        delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
+        return self.write_rows(headers, rows, delimiter=delimiter)
+
+    def write_rows(self, headers, rows, delimiter=None):
+        """
+        Return the given headers and rows as CSV, either as a string or (if STREAMING_EXPORTS is enabled) as an
+        iterator of lines.
+
+        Args:
+            headers: A list of column headers
+            rows: An iterator yielding a list of cell values for each row
+            delimiter: Name of a delimiter in utilities.constants.CSV_DELIMITERS (defaults to 'comma')
+        """
+        if delimiter and delimiter not in CSV_DELIMITERS:
+            raise ValueError(_("Invalid delimiter name: {name}").format(name=delimiter))
+        writer = csv.writer(_EchoBuffer(), delimiter=CSV_DELIMITERS[delimiter or 'comma'])
+        lines = (writer.writerow(row) for row in chain([headers], rows))
+        if settings.STREAMING_EXPORTS:
+            return lines
+        return ''.join(lines)
+
+
+class YAMLExporter(Exporter):
+    slug = 'yaml'
+    label = 'YAML'
+    content_type = 'text/yaml'
+    file_extension = 'yaml'
+
+    def supports_model(self, model):
+        return hasattr(model, 'to_yaml')
+
+    def export(self, *, request, queryset):
+        return '---\n'.join(obj.to_yaml() for obj in queryset)
+
+
+def register_exporter(cls):
+    """
+    Register an Exporter subclass. Can be used as a decorator.
+    """
+    if not issubclass(cls, Exporter):
+        raise ImproperlyConfigured(f"{cls.__name__} must be a subclass of netbox.exporters.Exporter.")
+    instance = cls()
+    for attr in ('slug', 'label', 'content_type', 'file_extension'):
+        if not getattr(instance, attr):
+            raise ImproperlyConfigured(f"{cls.__name__} must define a '{attr}' attribute.")
+    if not SLUG_RE.fullmatch(instance.slug):
+        raise ImproperlyConfigured(
+            f"Invalid exporter slug {instance.slug!r}: must be lowercase, start with a letter, and use only letters, "
+            f"digits, underscores, and dot-separated segments."
+        )
+    if instance.slug in registry['exporters']:
+        raise ImproperlyConfigured(f"An exporter named {instance.slug} has already been registered!")
+    registry['exporters'][instance.slug] = instance
+    return cls
+
+
+def get_exporter(slug):
+    return registry['exporters'].get(slug)
+
+
+def get_exporters(model):
+    """
+    Return all registered exporters which support the given model.
+    """
+    return [exporter for exporter in registry['exporters'].values() if exporter.supports_model(model)]

+ 4 - 4
netbox/netbox/object_actions.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
+from netbox.exporters import TableExporter, get_exporters
 from utilities.querydict import prepare_cloned_fields
 from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_action_url
 from utilities.views import get_action_url
 
 
@@ -151,9 +152,7 @@ class BulkExport(ObjectAction):
     def get_context(cls, context, model):
     def get_context(cls, context, model):
         object_type = ObjectType.objects.get_for_model(model)
         object_type = ObjectType.objects.get_for_model(model)
         user = context['request'].user
         user = context['request'].user
-
-        # Determine if the "all data" export returns CSV or YAML
-        data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
+        exporters = get_exporters(model)
 
 
         # Retrieve all export templates for this model
         # Retrieve all export templates for this model
         export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
         export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
@@ -161,8 +160,9 @@ class BulkExport(ObjectAction):
         return {
         return {
             'object_type': object_type,
             'object_type': object_type,
             'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
             'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
+            'table_exporters': [exporter for exporter in exporters if isinstance(exporter, TableExporter)],
+            'exporters': exporters,
             'export_templates': export_templates,
             'export_templates': export_templates,
-            'data_format': data_format,
         }
         }
 
 
 
 

+ 1 - 0
netbox/netbox/registry.py

@@ -27,6 +27,7 @@ registry = Registry({
     'data_backends': dict(),
     'data_backends': dict(),
     'event_rule_actions': dict(),
     'event_rule_actions': dict(),
     'event_types': dict(),
     'event_types': dict(),
+    'exporters': dict(),
     'filtersets': dict(),
     'filtersets': dict(),
     'model_actions': collections.defaultdict(set),
     'model_actions': collections.defaultdict(set),
     'model_features': dict(),
     'model_features': dict(),

+ 1 - 1
netbox/netbox/tests/dummy_plugin/__init__.py

@@ -27,7 +27,7 @@ class DummyPluginConfig(PluginConfig):
     def ready(self):
     def ready(self):
         super().ready()
         super().ready()
 
 
-        from . import jobs, webhook_callbacks, widgets  # noqa: F401
+        from . import exporters, jobs, webhook_callbacks, widgets  # noqa: F401
 
 
 
 
 config = DummyPluginConfig
 config = DummyPluginConfig

+ 35 - 0
netbox/netbox/tests/dummy_plugin/exporters.py

@@ -0,0 +1,35 @@
+from netbox.exporters import Exporter, TableExporter, register_exporter
+
+__all__ = (
+    'DummySiteExporter',
+    'DummyTableExporter',
+)
+
+
+@register_exporter
+class DummyTableExporter(TableExporter):
+    """Exports table rows as tab-separated values, encoded as bytes."""
+    slug = 'dummy_plugin.tsv'
+    label = 'Dummy TSV'
+    content_type = 'text/tab-separated-values; charset=utf-8'
+    file_extension = 'tsv'
+
+    def export_rows(self, *, request, headers, rows):
+        lines = ['\t'.join(headers), *('\t'.join('' if value is None else str(value) for value in row) for row in rows)]
+        return '\n'.join(lines).encode()
+
+
+@register_exporter
+class DummySiteExporter(Exporter):
+    """Streams the names of sites, one per line."""
+    slug = 'dummy_plugin.site_names'
+    label = 'Dummy Site Names'
+    content_type = 'text/plain; charset=utf-8'
+    file_extension = 'txt'
+
+    def supports_model(self, model):
+        return model._meta.label_lower == 'dcim.site'
+
+    def export(self, *, request, queryset):
+        for site in queryset:
+            yield f'{site.name}\n'

+ 231 - 0
netbox/netbox/tests/test_exporters.py

@@ -0,0 +1,231 @@
+import csv
+from io import StringIO
+from unittest import skipIf
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.http import StreamingHttpResponse
+from django.test import RequestFactory, SimpleTestCase, override_settings
+from django.urls import reverse
+
+from dcim.models import DeviceType, Manufacturer, Site
+from dcim.views import DeviceTypeListView, SiteListView
+from netbox.exporters import (
+    CSVExporter,
+    Exporter,
+    YAMLExporter,
+    get_exporter,
+    get_exporters,
+    register_exporter,
+)
+from netbox.registry import registry
+from utilities.testing import TestCase
+
+
+class ExporterRegistrationTestCase(SimpleTestCase):
+
+    def _register(self, cls):
+        self.addCleanup(registry['exporters'].pop, cls.slug, None)
+        return register_exporter(cls)
+
+    def test_core_exporters(self):
+        self.assertIsInstance(get_exporter('csv'), CSVExporter)
+        self.assertIsInstance(get_exporter('yaml'), YAMLExporter)
+
+    def test_get_exporters(self):
+        self.assertIn(get_exporter('csv'), get_exporters(Site))
+        self.assertNotIn(get_exporter('yaml'), get_exporters(Site))
+        self.assertIn(get_exporter('csv'), get_exporters(DeviceType))
+        self.assertIn(get_exporter('yaml'), get_exporters(DeviceType))
+
+    def test_register_exporter(self):
+        class TestExporter(Exporter):
+            slug = 'test.exporter'
+            label = 'Test'
+            content_type = 'text/plain'
+            file_extension = 'txt'
+
+        self.assertIs(self._register(TestExporter), TestExporter)
+        self.assertIsInstance(get_exporter('test.exporter'), TestExporter)
+
+    def test_register_exporter_not_subclass(self):
+        class TestExporter:
+            slug = 'test.exporter'
+            label = 'Test'
+            content_type = 'text/plain'
+            file_extension = 'txt'
+
+        with self.assertRaises(ImproperlyConfigured):
+            self._register(TestExporter)
+
+    def test_register_exporter_missing_attrs(self):
+        for attr in ('slug', 'label', 'content_type', 'file_extension'):
+            attrs = {
+                'slug': 'test.exporter',
+                'label': 'Test',
+                'content_type': 'text/plain',
+                'file_extension': 'txt',
+                attr: None,
+            }
+            with self.subTest(attr=attr), self.assertRaises(ImproperlyConfigured):
+                self._register(type('TestExporter', (Exporter,), attrs))
+
+    def test_register_exporter_invalid_slug(self):
+        for slug in ('Test', 'my-plugin.xlsx', '1test', 'test.', '.test'):
+            attrs = {'slug': slug, 'label': 'Test', 'content_type': 'text/plain', 'file_extension': 'txt'}
+            with self.subTest(slug=slug), self.assertRaises(ImproperlyConfigured):
+                self._register(type('TestExporter', (Exporter,), attrs))
+
+    def test_register_exporter_duplicate_slug(self):
+        class TestExporter(Exporter):
+            slug = 'csv'
+            label = 'Test'
+            content_type = 'text/plain'
+            file_extension = 'txt'
+
+        with self.assertRaises(ImproperlyConfigured):
+            register_exporter(TestExporter)
+        self.assertIsInstance(get_exporter('csv'), CSVExporter)
+
+
+class CSVExporterTestCase(SimpleTestCase):
+    headers = ['Name', 'Count']
+    rows = [['Site 1', 1], ['Site, 2', None]]
+
+    def test_write_rows(self):
+        data = get_exporter('csv').write_rows(self.headers, iter(self.rows))
+        self.assertEqual(data, 'Name,Count\r\nSite 1,1\r\n"Site, 2",\r\n')
+
+    def test_write_rows_delimiter(self):
+        data = get_exporter('csv').write_rows(self.headers, iter(self.rows), delimiter='semicolon')
+        self.assertEqual(data, 'Name;Count\r\nSite 1;1\r\nSite, 2;\r\n')
+
+    def test_write_rows_invalid_delimiter(self):
+        with self.assertRaises(ValueError):
+            get_exporter('csv').write_rows(self.headers, iter(self.rows), delimiter='invalid')
+
+    @override_settings(STREAMING_EXPORTS=True)
+    def test_write_rows_streaming(self):
+        data = get_exporter('csv').write_rows(self.headers, iter(self.rows))
+        self.assertEqual(list(data), ['Name,Count\r\n', 'Site 1,1\r\n', '"Site, 2",\r\n'])
+
+
+class ExportViewTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 4)])
+
+    def setUp(self):
+        super().setUp()
+        self.add_permissions('dcim.view_site')
+        self.url = reverse('dcim:site_list')
+        self.user.config.set('tables.SiteTable.columns', ['name', 'status'], commit=True)
+
+    def get_csv_rows(self, response):
+        return list(csv.reader(StringIO(response.content.decode())))
+
+    def test_export_current_view(self):
+        for params in ('export=table', 'export=table&export_format=csv'):
+            with self.subTest(params=params):
+                response = self.client.get(f'{self.url}?{params}')
+                self.assertHttpStatus(response, 200)
+                self.assertEqual(response['Content-Type'], 'text/csv; charset=utf-8')
+                self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.csv"')
+                rows = self.get_csv_rows(response)
+                self.assertEqual(rows[0], ['Name', 'Status'])
+                self.assertEqual(rows[1], ['Site 1', 'Active'])
+                self.assertEqual(len(rows), 4)
+
+    def test_export_all_data(self):
+        for params in ('export', 'export&export_format=csv'):
+            with self.subTest(params=params):
+                response = self.client.get(f'{self.url}?{params}')
+                self.assertHttpStatus(response, 200)
+                self.assertEqual(response['Content-Type'], 'text/csv; charset=utf-8')
+                rows = self.get_csv_rows(response)
+                self.assertEqual(rows[0][:2], ['Name', 'Status'])
+                self.assertGreater(len(rows[0]), 2)
+                self.assertEqual(len(rows), 4)
+
+    def test_export_filtered(self):
+        response = self.client.get(f'{self.url}?export=table&export_format=csv&q=Site 2')
+        self.assertEqual(self.get_csv_rows(response)[1:], [['Site 2', 'Active']])
+
+    def test_export_csv_delimiter(self):
+        self.user.config.set('csv_delimiter', 'semicolon', commit=True)
+        response = self.client.get(f'{self.url}?export=table&export_format=csv')
+        self.assertEqual(response.content.decode().splitlines()[0], 'Name;Status')
+
+    def test_export_invalid_format(self):
+        for params in (
+            'export&export_format=invalid',
+            'export=table&export_format=invalid',
+            'export&export_format=yaml',
+        ):
+            with self.subTest(params=params):
+                self.assertHttpStatus(self.client.get(f'{self.url}?{params}'), 404)
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
+    def test_export_plugin_table_exporter(self):
+        response = self.client.get(f'{self.url}?export=table&export_format=dummy_plugin.tsv')
+        self.assertHttpStatus(response, 200)
+        self.assertNotIsInstance(response, StreamingHttpResponse)
+        self.assertEqual(response['Content-Type'], 'text/tab-separated-values; charset=utf-8')
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.tsv"')
+        self.assertEqual(response.content.decode().splitlines()[:2], ['Name\tStatus', 'Site 1\tActive'])
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
+    def test_export_plugin_exporter(self):
+        response = self.client.get(f'{self.url}?export&export_format=dummy_plugin.site_names')
+        self.assertHttpStatus(response, 200)
+        self.assertIsInstance(response, StreamingHttpResponse)
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+        self.assertEqual(response.getvalue().decode(), 'Site 1\nSite 2\nSite 3\n')
+
+        # Not available for the current view
+        response = self.client.get(f'{self.url}?export=table&export_format=dummy_plugin.site_names')
+        self.assertHttpStatus(response, 404)
+
+        # Not available for other models
+        self.add_permissions('dcim.view_devicetype')
+        response = self.client.get(f'{reverse("dcim:devicetype_list")}?export&export_format=dummy_plugin.site_names')
+        self.assertHttpStatus(response, 404)
+
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
+    def test_export_dropdown(self):
+        response = self.client.get(f'{self.url}?status=active')
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, '?status=active&export=table&export_format=csv"')
+        self.assertContains(response, '?status=active&export=table&export_format=dummy_plugin.tsv"')
+        self.assertContains(response, '?status=active&export&export_format=csv"')
+        self.assertContains(response, '?status=active&export&export_format=dummy_plugin.tsv"')
+        self.assertContains(response, '?status=active&export&export_format=dummy_plugin.site_names"')
+        self.assertNotContains(response, 'export=table&export_format=dummy_plugin.site_names')
+        self.assertNotContains(response, 'export_format=yaml')
+
+    def test_export_table_override(self):
+        """
+        Overriding the deprecated export_table() still applies to CSV exports.
+        """
+        class CustomSiteListView(SiteListView):
+            def export_table(self, table, columns=None, filename=None, delimiter=None):
+                return super().export_table(table, columns, filename='custom.csv', delimiter=delimiter)
+
+        for params in ({'export': 'table'}, {'export': ''}, {'export': 'table', 'export_format': 'csv'}):
+            with self.subTest(params=params):
+                request = RequestFactory().get(self.url, params)
+                request.user = self.user
+                response = CustomSiteListView.as_view()(request)
+                self.assertEqual(response['Content-Disposition'], 'attachment; filename="custom.csv"')
+
+    def test_export_yaml(self):
+        """
+        The deprecated export_yaml() returns the YAML export of the view's queryset.
+        """
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        view = DeviceTypeListView()
+        view.request = RequestFactory().get('/')
+        view.queryset = DeviceType.objects.all()
+        self.assertIn('model: Device Type 1', view.export_yaml())

+ 8 - 0
netbox/netbox/tests/test_plugins.py

@@ -25,6 +25,7 @@ from netbox.plugins.utils import get_plugin_config
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.tests.dummy_plugin import config as dummy_config
 from netbox.tests.dummy_plugin import config as dummy_config
 from netbox.tests.dummy_plugin.data_backends import DummyBackend
 from netbox.tests.dummy_plugin.data_backends import DummyBackend
+from netbox.tests.dummy_plugin.exporters import DummySiteExporter, DummyTableExporter
 from netbox.tests.dummy_plugin.jobs import DummySystemJob
 from netbox.tests.dummy_plugin.jobs import DummySystemJob
 from netbox.tests.dummy_plugin.models import DummyModel
 from netbox.tests.dummy_plugin.models import DummyModel
 from netbox.tests.dummy_plugin.webhook_callbacks import set_context
 from netbox.tests.dummy_plugin.webhook_callbacks import set_context
@@ -156,6 +157,13 @@ class PluginTestCase(TestCase):
         self.assertIn('dummy', registry['data_backends'])
         self.assertIn('dummy', registry['data_backends'])
         self.assertIs(registry['data_backends']['dummy'], DummyBackend)
         self.assertIs(registry['data_backends']['dummy'], DummyBackend)
 
 
+    def test_exporters(self):
+        """
+        Check registered exporters.
+        """
+        self.assertIsInstance(registry['exporters']['dummy_plugin.tsv'], DummyTableExporter)
+        self.assertIsInstance(registry['exporters']['dummy_plugin.site_names'], DummySiteExporter)
+
     def test_system_jobs(self):
     def test_system_jobs(self):
         """
         """
         Check registered system jobs.
         Check registered system jobs.

+ 100 - 47
netbox/netbox/views/generic/bulk_views.py

@@ -5,7 +5,6 @@ from contextlib import contextmanager
 from copy import deepcopy
 from copy import deepcopy
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
-from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
 from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
 from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
@@ -13,11 +12,14 @@ from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
-from django.http import HttpResponse
+from django.http import Http404, HttpResponse, StreamingHttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.encoding import force_str
 from django.utils.http import content_disposition_header
 from django.utils.http import content_disposition_header
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+from django_tables2.data import TableQuerysetData
+from django_tables2.rows import BoundRow
 
 
 from core.exceptions import JobFailed
 from core.exceptions import JobFailed
 from core.models import ObjectType
 from core.models import ObjectType
@@ -25,12 +27,12 @@ from core.signals import clear_events
 from extras.choices import CustomFieldUIEditableChoices
 from extras.choices import CustomFieldUIEditableChoices
 from extras.models import CustomField, ExportTemplate
 from extras.models import CustomField, ExportTemplate
 from netbox.choices import ImportMethodChoices
 from netbox.choices import ImportMethodChoices
+from netbox.exporters import TableExporter, get_exporter
 from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm
 from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
-from utilities.export import TableExport, stream_table_csv_response
 from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields
 from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.htmx import htmx_partial
 from utilities.htmx import htmx_partial
@@ -57,6 +59,8 @@ __all__ = (
     'ObjectListView',
     'ObjectListView',
 )
 )
 
 
+EXPORT_CHUNK_SIZE = 1000
+
 
 
 # TODO: Remove in NetBox v5.0.
 # TODO: Remove in NetBox v5.0.
 # MPTT support is retained only for plugins whose tree models still derive from the
 # MPTT support is retained only for plugins whose tree models still derive from the
@@ -108,24 +112,22 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
     # Export methods
     # Export methods
     #
     #
 
 
-    def export_yaml(self):
-        """
-        Export the queryset of objects as concatenated YAML documents.
+    def get_export_queryset(self, exporter):
         """
         """
-        yaml_data = [obj.to_yaml() for obj in self.queryset]
+        Return the queryset of objects to be exported using the given exporter.
 
 
-        return '---\n'.join(yaml_data)
+        Args:
+            exporter: The Exporter instance
+        """
+        return self.queryset
 
 
-    def export_table(self, table, columns=None, filename=None, delimiter=None):
+    def get_export_rows(self, table, columns=None):
         """
         """
-        Export all table data in CSV format.
+        Return the column headers and an iterator of cell values for each row to be exported from the table.
 
 
         Args:
         Args:
             table: The Table instance to export
             table: The Table instance to export
             columns: A list of specific columns to include. If None, all columns will be exported.
             columns: A list of specific columns to include. If None, all columns will be exported.
-            filename: The name of the file attachment sent to the client. If None, will be determined automatically
-                from the queryset model name.
-            delimiter: The character used to separate columns (a comma is used by default)
         """
         """
         exclude_columns = {'pk', 'actions'}
         exclude_columns = {'pk', 'actions'}
         all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns]
         all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns]
@@ -138,23 +140,64 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
         # those currently visible in the configured table view.
         # those currently visible in the configured table view.
         table._apply_prefetching(columns=[c for c in all_columns if c not in exclude_columns])
         table._apply_prefetching(columns=[c for c in all_columns if c not in exclude_columns])
 
 
-        filename = filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
+        export_columns = [
+            column for column in table.columns.iterall()
+            if not (column.column.exclude_from_export or column.name in exclude_columns)
+        ]
+        headers = [force_str(column.header, strings_only=True) for column in export_columns]
 
 
-        if settings.STREAMING_EXPORTS:
-            return stream_table_csv_response(
-                table=table,
-                exclude_columns=exclude_columns,
-                filename=filename,
-                delimiter=delimiter,
-            )
+        def iter_rows():
+            if isinstance(table.data, TableQuerysetData):
+                records = table.data.data.iterator(chunk_size=EXPORT_CHUNK_SIZE)
+            else:
+                records = table.data
+            for record in records:
+                row = BoundRow(record, table=table)
+                yield [force_str(row.get_cell_value(column.name), strings_only=True) for column in export_columns]
 
 
-        exporter = TableExport(
-            export_format=TableExport.CSV,
-            table=table,
-            exclude_columns=exclude_columns,
-            delimiter=delimiter,
-        )
-        return exporter.response(filename=filename)
+        return headers, iter_rows()
+
+    def get_export_response(self, exporter, data, filename=None):
+        """
+        Return an HTTP response delivering the exported data as a file attachment.
+
+        Args:
+            exporter: The Exporter instance which produced the data
+            data: The exported data, as str or bytes, or as an iterable of chunks to be streamed
+            filename: The name of the file attachment. If None, will be determined automatically from the queryset
+                model name.
+        """
+        filename = filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.{exporter.file_extension}'
+        if isinstance(data, (str, bytes)):
+            response = HttpResponse(data, content_type=exporter.content_type)
+        else:
+            response = StreamingHttpResponse(data, content_type=exporter.content_type)
+        response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
+        return response
+
+    # TODO: Remove in NetBox v5.0
+    def export_yaml(self):
+        """
+        Deprecated: Export the queryset of objects as concatenated YAML documents.
+        """
+        exporter = get_exporter('yaml')
+        return exporter.export(request=self.request, queryset=self.get_export_queryset(exporter))
+
+    # TODO: Remove in NetBox v5.0
+    def export_table(self, table, columns=None, filename=None, delimiter=None):
+        """
+        Deprecated: Export all table data in CSV format.
+
+        Args:
+            table: The Table instance to export
+            columns: A list of specific columns to include. If None, all columns will be exported.
+            filename: The name of the file attachment sent to the client. If None, will be determined automatically
+                from the queryset model name.
+            delimiter: The character used to separate columns (a comma is used by default)
+        """
+        exporter = get_exporter('csv')
+        headers, rows = self.get_export_rows(table, columns)
+        return self.get_export_response(exporter, exporter.write_rows(headers, rows, delimiter), filename)
 
 
     def export_template(self, template, request):
     def export_template(self, template, request):
         """
         """
@@ -209,15 +252,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
 
         if 'export' in request.GET:
         if 'export' in request.GET:
 
 
-            # Export the current table view
-            if request.GET['export'] == 'table':
-                table = self.get_table(self.queryset, request, has_table_actions)
-                columns = [name for name, _ in table.selected_columns]
-                delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
-                return self.export_table(table, columns, delimiter=delimiter)
-
             # Render an ExportTemplate
             # Render an ExportTemplate
-            if request.GET['export']:
+            if request.GET['export'] not in ('', 'table'):
                 template = get_object_or_404(
                 template = get_object_or_404(
                     ExportTemplate.objects.restrict(request.user, 'view'),
                     ExportTemplate.objects.restrict(request.user, 'view'),
                     object_types=object_type,
                     object_types=object_type,
@@ -225,17 +261,34 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
                 )
                 )
                 return self.export_template(template, request)
                 return self.export_template(template, request)
 
 
-            # Check for YAML export support on the model
-            if hasattr(model, 'to_yaml'):
-                response = HttpResponse(self.export_yaml(), content_type='text/yaml')
-                filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
-                response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
-                return response
-
-            # Fall back to default table/YAML export
-            table = self.get_table(self.queryset, request, has_table_actions)
-            delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
-            return self.export_table(table, delimiter=delimiter)
+            # Export the current table view or all data, in the requested format (YAML or CSV by default)
+            current_view = request.GET['export'] == 'table'
+            if slug := request.GET.get('export_format'):
+                exporter = get_exporter(slug)
+                if not exporter or not exporter.supports_model(model) or (
+                    current_view and not isinstance(exporter, TableExporter)
+                ):
+                    raise Http404(_("Invalid export format: {format}").format(format=slug))
+            elif not current_view and get_exporter('yaml').supports_model(model):
+                exporter = get_exporter('yaml')
+            else:
+                exporter = get_exporter('csv')
+            queryset = self.get_export_queryset(exporter)
+            if not isinstance(exporter, TableExporter):
+                data = exporter.export(request=request, queryset=queryset)
+                return self.get_export_response(exporter, data)
+
+            table = self.get_table(queryset, request, has_table_actions)
+            columns = [name for name, _ in table.selected_columns] if current_view else None
+
+            # TODO: Remove in NetBox v5.0 along with export_table()
+            if exporter.slug == 'csv' and type(self).export_table is not ObjectListView.export_table:
+                delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
+                return self.export_table(table, columns, delimiter=delimiter)
+
+            headers, rows = self.get_export_rows(table, columns)
+            data = exporter.export_rows(request=request, headers=headers, rows=rows)
+            return self.get_export_response(exporter, data)
 
 
         # Render the objects table
         # Render the objects table
         table = self.get_table(self.queryset, request, has_table_actions)
         table = self.get_table(self.queryset, request, has_table_actions)

文件差异内容过多而无法显示
+ 0 - 0
netbox/project-static/dist/netbox.js


文件差异内容过多而无法显示
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 19 - 21
netbox/project-static/src/search.ts

@@ -18,32 +18,30 @@ function quickSearchEventHandler(event: Event): void {
 }
 }
 
 
 /**
 /**
- * Clear the existing search parameters in the link to export Current View.
+ * Update the links to export the current view with the quick search query.
+ *
+ * @param query Quick search query
  */
  */
-function clearLinkParams(): void {
-  const link = document.getElementById('export_current_view') as HTMLLinkElement;
-  const linkUpdated = link?.href.split('&')[0];
-  link.setAttribute('href', linkUpdated);
+function updateExportLinks(query: string): void {
+  for (const link of document.querySelectorAll<HTMLAnchorElement>('a.export-current-view')) {
+    const url = new URL(link.href);
+    if (query) {
+      url.searchParams.set('q', query);
+    } else {
+      url.searchParams.delete('q');
+    }
+    link.href = url.toString();
+  }
 }
 }
 
 
 /**
 /**
- * Update the Export View link to add the Quick Search parameters.
- * @param event
+ * Update the export links to include the quick search query.
+ *
+ * @param event "change" event for the quicksearch input
  */
  */
 function handleQuickSearchParams(event: Event): void {
 function handleQuickSearchParams(event: Event): void {
-  const quickSearchParameters = event.currentTarget as HTMLInputElement;
-
-  // Clear the existing search parameters
-  clearLinkParams();
-
-  if (quickSearchParameters != null) {
-    const link = document.getElementById('export_current_view') as HTMLLinkElement;
-    const params = new URLSearchParams();
-    params.set('q', quickSearchParameters.value);
-    const search_parameter = params.toString();
-    const linkUpdated = link?.href + '&' + search_parameter;
-    link.setAttribute('href', linkUpdated);
-  }
+  const quicksearch = event.currentTarget as HTMLInputElement;
+  updateExportLinks(quicksearch.value);
 }
 }
 
 
 /**
 /**
@@ -71,7 +69,7 @@ export function initQuickSearch(): void {
           quicksearch.value = '';
           quicksearch.value = '';
           await new Promise(f => setTimeout(f, 100));
           await new Promise(f => setTimeout(f, 100));
           quicksearch.dispatchEvent(search);
           quicksearch.dispatchEvent(search);
-          clearLinkParams();
+          updateExportLinks('');
         },
         },
         {
         {
           passive: true,
           passive: true,

+ 0 - 93
netbox/utilities/export.py

@@ -1,93 +0,0 @@
-import csv
-
-from django.http import StreamingHttpResponse
-from django.utils.encoding import force_str
-from django.utils.http import content_disposition_header
-from django.utils.translation import gettext_lazy as _
-from django_tables2.data import TableQuerysetData
-from django_tables2.export import TableExport as TableExport_
-from django_tables2.rows import BoundRow
-
-from utilities.constants import CSV_DELIMITERS
-
-__all__ = (
-    'TableExport',
-    'stream_table_csv_response',
-)
-
-EXPORT_CHUNK_SIZE = 1000
-
-
-class TableExport(TableExport_):
-    """
-    A subclass of django-tables2's TableExport class which allows us to specify a delimiting
-    characters for CSV exports.
-    """
-    def __init__(self, *args, delimiter=None, **kwargs):
-        if delimiter and delimiter not in CSV_DELIMITERS.keys():
-            raise ValueError(_("Invalid delimiter name: {name}").format(name=delimiter))
-        self.delimiter = delimiter or 'comma'
-        super().__init__(*args, **kwargs)
-
-    def export(self):
-        if self.format == self.CSV and self.delimiter is not None:
-            delimiter = CSV_DELIMITERS[self.delimiter]
-            return self.dataset.export(self.format, delimiter=delimiter)
-        return super().export()
-
-
-class _EchoBuffer:
-    """
-    File-like object whose write() simply returns the value written, so csv.writer output can be
-    captured row-by-row and fed to a StreamingHttpResponse.
-    """
-    def write(self, value):
-        return value
-
-
-def stream_table_csv_response(table, exclude_columns=None, filename=None, delimiter=None):
-    """
-    Return a StreamingHttpResponse that emits the given table's rows as CSV without first buffering
-    the entire dataset in memory. Queryset-backed tables are iterated in chunks using
-    QuerySet.iterator() to cap peak memory; prefetched relations are preserved under Django 4.1+.
-
-    Args:
-        table: The django-tables2 Table instance to export
-        exclude_columns: Iterable of column names to omit from the export
-        filename: If set, a Content-Disposition header is included in the HTTP response, indicating its treatment
-            as a file attachment with the specified name.
-        delimiter: Name of a delimiter in utilities.constants.CSV_DELIMITERS (defaults to 'comma')
-    """
-    if delimiter and delimiter not in CSV_DELIMITERS:
-        raise ValueError(_("Invalid delimiter name: {name}").format(name=delimiter))
-    csv_delimiter = CSV_DELIMITERS[delimiter or 'comma']
-    exclude_columns = exclude_columns or set()
-
-    columns = [
-        column for column in table.columns.iterall()
-        if not (column.column.exclude_from_export or column.name in exclude_columns)
-    ]
-
-    writer = csv.writer(_EchoBuffer(), delimiter=csv_delimiter)
-
-    def iter_records():
-        if isinstance(table.data, TableQuerysetData):
-            yield from table.data.data.iterator(chunk_size=EXPORT_CHUNK_SIZE)
-        else:
-            yield from table.data
-
-    def row_generator():
-        yield writer.writerow([
-            force_str(column.header, strings_only=True) for column in columns
-        ])
-        for record in iter_records():
-            row = BoundRow(record, table=table)
-            yield writer.writerow([
-                force_str(row.get_cell_value(column.name), strings_only=True)
-                for column in columns
-            ])
-
-    response = StreamingHttpResponse(row_generator(), content_type='text/csv; charset=utf-8')
-    if filename is not None:
-        response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
-    return response

+ 12 - 2
netbox/utilities/templates/buttons/export.html

@@ -4,8 +4,18 @@
     <i class="mdi mdi-download" aria-hidden="true"></i> {{ label }}
     <i class="mdi mdi-download" aria-hidden="true"></i> {{ label }}
   </button>
   </button>
   <ul class="dropdown-menu dropdown-menu-end">
   <ul class="dropdown-menu dropdown-menu-end">
-    <li><a id="export_current_view" class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li>
-    <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
+    {% if table_exporters %}
+      <li><h6 class="dropdown-header">{% trans "Current View" %}</h6></li>
+      {% for exporter in table_exporters %}
+        <li><a class="dropdown-item export-current-view" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table&export_format={{ exporter.slug }}">{{ exporter.label }}</a></li>
+      {% endfor %}
+    {% endif %}
+    {% if exporters %}
+      <li><h6 class="dropdown-header">{% trans "All Data" %}</h6></li>
+      {% for exporter in exporters %}
+        <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export&export_format={{ exporter.slug }}">{{ exporter.label }}</a></li>
+      {% endfor %}
+    {% endif %}
     {% if export_templates %}
     {% if export_templates %}
       <li>
       <li>
         <hr class="dropdown-divider">
         <hr class="dropdown-divider">

+ 1 - 25
netbox/utilities/templatetags/buttons.py

@@ -4,8 +4,7 @@ from django.urls import NoReverseMatch, reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from core.models import ObjectType
-from extras.models import Bookmark, ExportTemplate, Subscription
+from extras.models import Bookmark, Subscription
 from netbox.models.features import NotificationsMixin
 from netbox.models.features import NotificationsMixin
 from utilities.querydict import prepare_cloned_fields
 from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_action_url
 from utilities.views import get_action_url
@@ -19,7 +18,6 @@ __all__ = (
     'clone_button',
     'clone_button',
     'delete_button',
     'delete_button',
     'edit_button',
     'edit_button',
-    'export_button',
     'import_button',
     'import_button',
     'subscribe_button',
     'subscribe_button',
     'sync_button',
     'sync_button',
@@ -191,28 +189,6 @@ def import_button(model, action='bulk_import'):
     }
     }
 
 
 
 
-# TODO: Remove in NetBox v4.7
-@register.inclusion_tag('buttons/export.html', takes_context=True)
-def export_button(context, model):
-    object_type = ObjectType.objects.get_for_model(model)
-    user = context['request'].user
-
-    # Determine if the "all data" export returns CSV or YAML
-    data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
-
-    # Retrieve all export templates for this model
-    export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
-
-    return {
-        'label': _('Export'),
-        'perms': context['perms'],
-        'object_type': object_type,
-        'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
-        'export_templates': export_templates,
-        'data_format': data_format,
-    }
-
-
 # TODO: Remove in NetBox v4.7
 # TODO: Remove in NetBox v4.7
 @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
 @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
 def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
 def bulk_edit_button(context, model, action='bulk_edit', query_params=None):

+ 12 - 0
netbox/utilities/testing/views.py

@@ -11,6 +11,7 @@ from django.utils.translation import gettext as _
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
+from netbox.exporters import TableExporter, get_exporters
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from users.models import ObjectPermission
 from users.models import ObjectPermission
 
 
@@ -517,6 +518,17 @@ class ViewTestCases:
             self.assertHttpStatus(response, 200)
             self.assertHttpStatus(response, 200)
             self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
             self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
 
 
+            # Test each supported exporter for the current view (table exporters only) and for all data
+            for exporter in get_exporters(self.model):
+                scopes = ('export=table', 'export') if isinstance(exporter, TableExporter) else ('export',)
+                for scope in scopes:
+                    with self.subTest(exporter=exporter.slug, scope=scope):
+                        response = self.client.get(f'{url}?{scope}&export_format={exporter.slug}')
+                        self.assertHttpStatus(response, 200)
+                        self.assertEqual(response.get('Content-Type'), exporter.content_type)
+                        self.assertIn(f'.{exporter.file_extension}', response.get('Content-Disposition'))
+                        self.assertTrue(response.getvalue())
+
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
         def test_export_objects_anonymous(self):
         def test_export_objects_anonymous(self):
             # Ensure we are logged out.
             # Ensure we are logged out.

+ 0 - 1
requirements.txt

@@ -40,6 +40,5 @@ sorl-thumbnail==13.4.0
 strawberry-graphql==0.338.0
 strawberry-graphql==0.338.0
 strawberry-graphql-django==0.93.0
 strawberry-graphql-django==0.93.0
 svgwrite==1.4.3
 svgwrite==1.4.3
-tablib==3.10.0
 tzdata==2026.5
 tzdata==2026.5
 zensical==0.0.73
 zensical==0.0.73

部分文件因为文件数量过多而无法显示