Просмотр исходного кода

Retain utilities.export and tablib as deprecated until NetBox v5.0

Jeremy Stretch 3 дней назад
Родитель
Сommit
04d569b98b
4 измененных файлов с 150 добавлено и 0 удалено
  1. 6 0
      base_requirements.txt
  2. 102 0
      netbox/utilities/export.py
  3. 41 0
      netbox/utilities/tests/test_export.py
  4. 1 0
      requirements.txt

+ 6 - 0
base_requirements.txt

@@ -173,6 +173,12 @@ strawberry-graphql-django
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst
 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
+# TODO: Remove in NetBox v5.0 along with utilities.export
+tablib
+
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # https://github.com/python/tzdata/blob/master/NEWS.md
 tzdata

+ 102 - 0
netbox/utilities/export.py

@@ -0,0 +1,102 @@
+# TODO: Remove this module in NetBox v5.0
+import csv
+import warnings
+
+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',
+)
+
+warnings.warn(
+    "utilities.export is deprecated and will be removed in NetBox v5.0. Register an exporter using "
+    "netbox.exporters.register_exporter() instead.",
+    DeprecationWarning,
+    stacklevel=2,
+)
+
+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

+ 41 - 0
netbox/utilities/tests/test_export.py

@@ -0,0 +1,41 @@
+import importlib
+import sys
+
+from django.test import TestCase
+
+from dcim.models import Site
+from dcim.tables import SiteTable
+
+
+# TODO: Remove in NetBox v5.0 along with utilities.export
+class DeprecatedExportModuleTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.create(name='Site 1', slug='site-1')
+
+    def setUp(self):
+        # Re-import the module so that its deprecation warning is raised again
+        sys.modules.pop('utilities.export', None)
+        with self.assertWarns(DeprecationWarning):
+            self.export = importlib.import_module('utilities.export')
+        self.table = SiteTable(Site.objects.all())
+
+    def test_table_export(self):
+        exporter = self.export.TableExport(
+            export_format=self.export.TableExport.CSV,
+            table=self.table,
+            exclude_columns={'pk', 'actions'},
+            delimiter='semicolon',
+        )
+        response = exporter.response(filename='sites.csv')
+        self.assertIn('Site 1;', response.content.decode())
+
+    def test_stream_table_csv_response(self):
+        response = self.export.stream_table_csv_response(
+            table=self.table,
+            exclude_columns={'pk', 'actions'},
+            filename='sites.csv',
+        )
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="sites.csv"')
+        self.assertIn('Site 1,', response.getvalue().decode())

+ 1 - 0
requirements.txt

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