浏览代码

Merge TableExporter into Exporter

Tabular exporters now set tabular = True and receive the table's headers and rows as optional arguments to export(), replacing export_rows().
Jeremy Stretch 3 天之前
父节点
当前提交
b5399e8b43

+ 9 - 10
docs/plugins/development/exporters.md

@@ -2,25 +2,26 @@
 
 !!! 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 certain object 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.
+The "Export" button on object list views offers a choice of export formats. NetBox provides CSV and (for certain object types) YAML exporters. Plugins can register their own exporters to add formats, such as XLSX or JSON, by subclassing NetBox's `Exporter` class and implementing its `export()` method.
 
-A `TableExporter` exports the rows of the list view's table. NetBox resolves the table's columns and calls the exporter's `export_rows()` method (rather than `export()`) with 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).
+An exporter with `tabular = True` exports the rows of the list view's table. NetBox resolves the table's columns and passes `export()` a list of column headers (`headers`) and an iterator of cell values for each row (`rows`). Tabular 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
+from netbox.exporters import Exporter, register_exporter
 
 @register_exporter
-class XLSXExporter(TableExporter):
+class XLSXExporter(Exporter):
     slug = 'my_plugin.xlsx'
     label = 'Excel (XLSX)'
     content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
     file_extension = 'xlsx'
+    tabular = True
 
-    def export_rows(self, *, request, headers, rows):
+    def export(self, *, request, queryset, headers=None, rows=None):
         workbook = Workbook(write_only=True)
         sheet = workbook.create_sheet()
         sheet.append(headers)
@@ -31,7 +32,7 @@ class XLSXExporter(TableExporter):
         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.
+A non-tabular 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
@@ -46,11 +47,11 @@ class VendorDeviceTypeExporter(Exporter):
     def supports_model(self, model):
         return model._meta.label == 'dcim.DeviceType'
 
-    def export(self, *, request, queryset):
+    def export(self, *, request, queryset, headers=None, rows=None):
         ...
 ```
 
-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. NetBox streams an iterable to the client if [`STREAMING_EXPORTS`](../../configuration/miscellaneous.md#streaming_exports) is enabled; otherwise, it collects the chunks before responding.
+The queryset passed to an exporter has the user's filters applied, and is restricted to objects the user has permission to view. `export()` returns the exported data as a string or bytes, or as an iterable of chunks. NetBox streams an iterable to the client if [`STREAMING_EXPORTS`](../../configuration/miscellaneous.md#streaming_exports) is enabled; otherwise, it collects the chunks before responding.
 
 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:
 
@@ -102,5 +103,3 @@ The `export_format` query parameter selects an exporter by its slug:
 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

+ 11 - 26
netbox/netbox/exporters.py

@@ -11,7 +11,6 @@ from utilities.constants import CSV_DELIMITERS, SLUG_RE
 __all__ = (
     'CSVExporter',
     'Exporter',
-    'TableExporter',
     'YAMLExporter',
     'get_exporter',
     'get_exporters',
@@ -21,7 +20,7 @@ __all__ = (
 
 class Exporter:
     """
-    Base class for an export format offered on object list views. Exporters are listed under "All Data" only.
+    Base class for an export format offered on object list views.
 
     Registration instantiates the class once, and that instance serves every request, so implementations must be
     stateless.
@@ -32,11 +31,14 @@ class Exporter:
         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")
+        tabular: If True, export() receives the rows of the list view's table, and the exporter is listed under both
+            "Current View" and "All Data". Otherwise, it is listed under "All Data" only.
     """
     slug = None
     label = None
     content_type = None
     file_extension = None
+    tabular = False
 
     def __repr__(self):
         return f'<{self.__class__.__name__}: {self.slug}>'
@@ -47,7 +49,7 @@ class Exporter:
         """
         return True
 
-    def export(self, *, request, queryset):
+    def export(self, *, request, queryset, headers=None, rows=None):
         """
         Return the exported data as str or bytes, or as an iterable of chunks (streamed if STREAMING_EXPORTS is
         enabled).
@@ -55,30 +57,12 @@ class Exporter:
         Args:
             request: The current request
             queryset: The filtered queryset of objects to export, restricted to those the user can view
+            headers: A list of the table's column headers (tabular exporters only)
+            rows: An iterator yielding a list of cell values for each table row (tabular exporters only)
         """
         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". Subclasses implement export_rows() rather than export().
-    """
-    def export(self, *, request, queryset):
-        raise NotImplementedError(f"{self.__class__.__name__} is a table exporter; call export_rows() instead")
-
-    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.
@@ -87,13 +71,14 @@ class _EchoBuffer:
         return value
 
 
-class CSVExporter(TableExporter):
+class CSVExporter(Exporter):
     slug = 'csv'
     label = 'CSV'
     content_type = 'text/csv; charset=utf-8'
     file_extension = 'csv'
+    tabular = True
 
-    def export_rows(self, *, request, headers, rows):
+    def export(self, *, request, queryset, headers=None, rows=None):
         delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
         return self.write_rows(headers, rows, delimiter=delimiter)
 
@@ -121,7 +106,7 @@ class YAMLExporter(Exporter):
     def supports_model(self, model):
         return hasattr(model, 'to_yaml')
 
-    def export(self, *, request, queryset):
+    def export(self, *, request, queryset, headers=None, rows=None):
         # Prefetch the relations named by the model, and batch any other foreign key lookups across all objects
         queryset = queryset.prefetch_related(*getattr(queryset.model, 'yaml_prefetch', ())).fetch_mode(FETCH_PEERS)
         return '---\n'.join(obj.to_yaml() for obj in queryset)

+ 2 - 2
netbox/netbox/object_actions.py

@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
 from extras.models import ExportTemplate
-from netbox.exporters import TableExporter, get_exporters
+from netbox.exporters import get_exporters
 from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_action_url
 
@@ -160,7 +160,7 @@ class BulkExport(ObjectAction):
         return {
             'object_type': object_type,
             'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
-            'table_exporters': [exporter for exporter in exporters if isinstance(exporter, TableExporter)],
+            'table_exporters': [exporter for exporter in exporters if exporter.tabular],
             'exporters': exporters,
             'export_templates': export_templates,
         }

+ 5 - 4
netbox/netbox/tests/dummy_plugin/exporters.py

@@ -1,4 +1,4 @@
-from netbox.exporters import Exporter, TableExporter, register_exporter
+from netbox.exporters import Exporter, register_exporter
 
 __all__ = (
     'DummySiteExporter',
@@ -7,14 +7,15 @@ __all__ = (
 
 
 @register_exporter
-class DummyTableExporter(TableExporter):
+class DummyTableExporter(Exporter):
     """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'
+    tabular = True
 
-    def export_rows(self, *, request, headers, rows):
+    def export(self, *, request, queryset, headers=None, rows=None):
         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()
 
@@ -30,6 +31,6 @@ class DummySiteExporter(Exporter):
     def supports_model(self, model):
         return model._meta.label_lower == 'dcim.site'
 
-    def export(self, *, request, queryset):
+    def export(self, *, request, queryset, headers=None, rows=None):
         for site in queryset:
             yield f'{site.name}\n'

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

@@ -109,10 +109,6 @@ class CSVExporterTestCase(SimpleTestCase):
         with self.assertRaises(ValueError):
             get_exporter('csv').write_rows(self.headers, iter(self.rows), delimiter='invalid')
 
-    def test_export(self):
-        with self.assertRaisesMessage(NotImplementedError, 'call export_rows() instead'):
-            get_exporter('csv').export(request=None, queryset=None)
-
 
 class YAMLExporterTestCase(TestCase):
 

+ 4 - 4
netbox/netbox/views/generic/bulk_views.py

@@ -28,7 +28,7 @@ from core.signals import clear_events
 from extras.choices import CustomFieldUIEditableChoices
 from extras.models import CustomField, ExportTemplate
 from netbox.choices import ImportMethodChoices
-from netbox.exporters import TableExporter, get_exporter
+from netbox.exporters import get_exporter
 from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm
 from netbox.models.features import ChangeLoggingMixin
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
@@ -261,14 +261,14 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             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)
+                    current_view and not exporter.tabular
                 ):
                     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')
-            if not isinstance(exporter, TableExporter):
+            if not exporter.tabular:
                 # TODO: Remove in NetBox v5.0 along with export_yaml()
                 if exporter.slug == 'yaml' and type(self).export_yaml is not ObjectListView.export_yaml:
                     return self.get_export_response(exporter, self.export_yaml())
@@ -284,7 +284,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
                 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)
+            data = exporter.export(request=request, queryset=self.queryset, headers=headers, rows=rows)
             return self.get_export_response(exporter, data)
 
         # Render the objects table

+ 2 - 2
netbox/utilities/testing/views.py

@@ -11,7 +11,7 @@ from django.utils.translation import gettext as _
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
-from netbox.exporters import TableExporter, get_exporters
+from netbox.exporters import get_exporters
 from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
 from users.models import ObjectPermission
 
@@ -520,7 +520,7 @@ class ViewTestCases:
 
             # 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',)
+                scopes = ('export=table', 'export') if exporter.tabular 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}')