336 lines
13 KiB
Python
336 lines
13 KiB
Python
|
|
import csv
|
|||
|
|
import datetime
|
|||
|
|
from collections import OrderedDict
|
|||
|
|
from functools import partial
|
|||
|
|
from io import BytesIO
|
|||
|
|
|
|||
|
|
from django.contrib.admin.utils import label_for_field
|
|||
|
|
from django.core.exceptions import FieldDoesNotExist
|
|||
|
|
from django.http import FileResponse, StreamingHttpResponse
|
|||
|
|
from django.utils import timezone
|
|||
|
|
from django.utils.dateformat import Formatter
|
|||
|
|
from django.utils.encoding import force_str
|
|||
|
|
from django.utils.formats import get_format
|
|||
|
|
from django.utils.functional import cached_property
|
|||
|
|
from django.utils.text import capfirst
|
|||
|
|
from django.utils.translation import gettext as _
|
|||
|
|
|
|||
|
|
from wagtail.admin.widgets.button import Button
|
|||
|
|
from wagtail.coreutils import multigetattr
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Echo:
|
|||
|
|
"""An object that implements just the write method of the file-like interface."""
|
|||
|
|
|
|||
|
|
def write(self, value):
|
|||
|
|
"""Write the value by returning it, instead of storing in a buffer."""
|
|||
|
|
return value.encode("UTF-8")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_to_str(value):
|
|||
|
|
return force_str(", ".join(value))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ExcelDateFormatter(Formatter):
|
|||
|
|
data = None
|
|||
|
|
|
|||
|
|
# From: https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
|
|||
|
|
# To: https://support.microsoft.com/en-us/office/format-numbers-as-dates-or-times-418bd3fe-0577-47c8-8caa-b4d30c528309#bm2
|
|||
|
|
_formats = {
|
|||
|
|
# Day of the month, 2 digits with leading zeros.
|
|||
|
|
"d": "dd",
|
|||
|
|
# Day of the month without leading zeros.
|
|||
|
|
"j": "d",
|
|||
|
|
# Day of the week, textual, 3 letters.
|
|||
|
|
"D": "ddd",
|
|||
|
|
# Day of the week, textual, full.
|
|||
|
|
"l": "dddd",
|
|||
|
|
# English ordinal suffix for the day of the month, 2 characters.
|
|||
|
|
"S": "", # Not supported in Excel
|
|||
|
|
# Day of the week, digits without leading zeros.
|
|||
|
|
"w": "", # Not supported in Excel
|
|||
|
|
# Day of the year.
|
|||
|
|
"z": "", # Not supported in Excel
|
|||
|
|
# ISO-8601 week number of year, with weeks starting on Monday.
|
|||
|
|
"W": "", # Not supported in Excel
|
|||
|
|
# Month, 2 digits with leading zeros.
|
|||
|
|
"m": "mm",
|
|||
|
|
# Month without leading zeros.
|
|||
|
|
"n": "m",
|
|||
|
|
# Month, textual, 3 letters.
|
|||
|
|
"M": "mmm",
|
|||
|
|
# Month, textual, 3 letters, lowercase. (Not supported in Excel)
|
|||
|
|
"b": "mmm",
|
|||
|
|
# Month, locale specific alternative representation usually used for long date representation.
|
|||
|
|
"E": "mmmm", # Not supported in Excel
|
|||
|
|
# Month, textual, full.
|
|||
|
|
"F": "mmmm",
|
|||
|
|
# Month abbreviation in Associated Press style. Proprietary extension.
|
|||
|
|
"N": "mmm.", # Approximation, wrong for May
|
|||
|
|
# Number of days in the given month.
|
|||
|
|
"t": "", # Not supported in Excel
|
|||
|
|
# Year, 2 digits with leading zeros.
|
|||
|
|
"y": "yy",
|
|||
|
|
# Year, 4 digits with leading zeros.
|
|||
|
|
"Y": "yyyy",
|
|||
|
|
# Whether it's a leap year.
|
|||
|
|
"L": "", # Not supported in Excel
|
|||
|
|
# ISO-8601 week-numbering year.
|
|||
|
|
"o": "yyyy", # Approximation, same as Y
|
|||
|
|
# Hour, 12-hour format without leading zeros.
|
|||
|
|
"g": "h", # Only works when combined with AM/PM, 24-hour format is used otherwise
|
|||
|
|
# Hour, 24-hour format without leading zeros.
|
|||
|
|
"G": "hH",
|
|||
|
|
# Hour, 12-hour format with leading zeros.
|
|||
|
|
"h": "hh", # Only works when combined with AM/PM, 24-hour format is used otherwise
|
|||
|
|
# Hour, 24-hour format with leading zeros.
|
|||
|
|
"H": "hh",
|
|||
|
|
# Minutes.
|
|||
|
|
"i": "mm",
|
|||
|
|
# Seconds.
|
|||
|
|
"s": "ss",
|
|||
|
|
# Microseconds.
|
|||
|
|
"u": ".00", # Only works when combined with ss
|
|||
|
|
# 'a.m.' or 'p.m.'.
|
|||
|
|
"a": "AM/PM", # Approximation, uses AM/PM and only works when combined with h/hh
|
|||
|
|
# AM/PM.
|
|||
|
|
"A": "AM/PM", # Only works when combined with h/hh
|
|||
|
|
# Time, in 12-hour hours and minutes, with minutes left off if they’re zero.
|
|||
|
|
"f": "h:mm", # Approximation, uses 24-hour format and minutes are never left off
|
|||
|
|
# Time, in 12-hour hours, minutes and ‘a.m.’/’p.m.’, with minutes left off if they’re zero and the special-case strings ‘midnight’ and ‘noon’ if appropriate.
|
|||
|
|
"P": "h:mm AM/PM", # Approximation, minutes are never left off, no special case strings
|
|||
|
|
# Timezone name.
|
|||
|
|
"e": "", # Not supported in Excel
|
|||
|
|
# Daylight saving time, whether it’s in effect or not.
|
|||
|
|
"I": "", # Not supported in Excel
|
|||
|
|
# Difference to Greenwich time in hours.
|
|||
|
|
"O": "", # Not supported in Excel
|
|||
|
|
# Time zone of this machine.
|
|||
|
|
"T": "", # Not supported in Excel
|
|||
|
|
# Timezone offset in seconds.
|
|||
|
|
"Z": "", # Not supported in Excel
|
|||
|
|
# ISO 8601 format.
|
|||
|
|
"c": "yyyy-mm-ddThh:mm:ss.00",
|
|||
|
|
# RFC 5322 formatted date.
|
|||
|
|
"r": "ddd, d mmm yyyy hh:mm:ss",
|
|||
|
|
# Seconds since the Unix epoch.
|
|||
|
|
"U": "", # Not supported in Excel
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get(self):
|
|||
|
|
format = get_format("SHORT_DATETIME_FORMAT")
|
|||
|
|
return self.format(format)
|
|||
|
|
|
|||
|
|
def __getattr__(self, name):
|
|||
|
|
if name in self._formats:
|
|||
|
|
return lambda: self._formats[name]
|
|||
|
|
raise AttributeError(
|
|||
|
|
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SpreadsheetExportMixin:
|
|||
|
|
"""A mixin for views, providing spreadsheet export functionality in csv and xlsx formats"""
|
|||
|
|
|
|||
|
|
FORMAT_XLSX = "xlsx"
|
|||
|
|
FORMAT_CSV = "csv"
|
|||
|
|
FORMATS = (FORMAT_XLSX, FORMAT_CSV)
|
|||
|
|
|
|||
|
|
# A list of fields or callables (without arguments) to export from each item in the queryset (dotted paths allowed)
|
|||
|
|
list_export = []
|
|||
|
|
# A dictionary of custom preprocessing functions by field and format (expected value would be of the form {field_name: {format: function}})
|
|||
|
|
# If a valid field preprocessing function is found, any applicable value preprocessing functions will not be used
|
|||
|
|
custom_field_preprocess = {}
|
|||
|
|
# A dictionary of preprocessing functions by value class and format
|
|||
|
|
custom_value_preprocess = {
|
|||
|
|
datetime.datetime: {
|
|||
|
|
FORMAT_XLSX: lambda value: (
|
|||
|
|
value
|
|||
|
|
if timezone.is_naive(value)
|
|||
|
|
else timezone.make_naive(value, datetime.timezone.utc)
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
(datetime.date, datetime.time): {FORMAT_XLSX: None},
|
|||
|
|
list: {FORMAT_CSV: list_to_str, FORMAT_XLSX: list_to_str},
|
|||
|
|
}
|
|||
|
|
# A dictionary of column heading overrides in the format {field: heading}
|
|||
|
|
export_headings = {}
|
|||
|
|
|
|||
|
|
export_buttons_template_name = "wagtailadmin/shared/export_buttons.html"
|
|||
|
|
|
|||
|
|
export_filename = "spreadsheet-export"
|
|||
|
|
|
|||
|
|
def setup(self, request, *args, **kwargs):
|
|||
|
|
super().setup(request, *args, **kwargs)
|
|||
|
|
self.is_export = request.GET.get("export") in self.FORMATS
|
|||
|
|
|
|||
|
|
def get_paginate_by(self, queryset):
|
|||
|
|
if self.is_export:
|
|||
|
|
return None
|
|||
|
|
return super().get_paginate_by(queryset)
|
|||
|
|
|
|||
|
|
def get_filename(self):
|
|||
|
|
"""Gets the base filename for the exported spreadsheet, without extensions"""
|
|||
|
|
return self.export_filename
|
|||
|
|
|
|||
|
|
def to_row_dict(self, item):
|
|||
|
|
"""Returns an OrderedDict (in the order given by list_export) of the exportable information for a model instance"""
|
|||
|
|
row_dict = OrderedDict(
|
|||
|
|
(field, multigetattr(item, field)) for field in self.list_export
|
|||
|
|
)
|
|||
|
|
return row_dict
|
|||
|
|
|
|||
|
|
def get_preprocess_function(self, field, value, export_format):
|
|||
|
|
"""Returns the preprocessing function for a given field name, field value, and export format"""
|
|||
|
|
|
|||
|
|
# Try to find a field specific function and return it
|
|||
|
|
format_dict = self.custom_field_preprocess.get(field, {})
|
|||
|
|
if export_format in format_dict:
|
|||
|
|
return format_dict[export_format]
|
|||
|
|
|
|||
|
|
# Otherwise check for a value class specific function
|
|||
|
|
for value_classes, format_dict in self.custom_value_preprocess.items():
|
|||
|
|
if isinstance(value, value_classes) and export_format in format_dict:
|
|||
|
|
return format_dict[export_format]
|
|||
|
|
|
|||
|
|
# Finally resort to force_str to prevent encoding errors
|
|||
|
|
return partial(force_str, strings_only=True)
|
|||
|
|
|
|||
|
|
def preprocess_field_value(self, field, value, export_format):
|
|||
|
|
"""Preprocesses a field value before writing it to the spreadsheet"""
|
|||
|
|
preprocess_function = self.get_preprocess_function(field, value, export_format)
|
|||
|
|
if preprocess_function is not None:
|
|||
|
|
return preprocess_function(value)
|
|||
|
|
else:
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
def generate_xlsx_row(self, worksheet, row_dict, date_format=None):
|
|||
|
|
"""Generate cells to append to the worksheet"""
|
|||
|
|
from openpyxl.cell import WriteOnlyCell
|
|||
|
|
|
|||
|
|
for field, value in row_dict.items():
|
|||
|
|
cell = WriteOnlyCell(
|
|||
|
|
worksheet, self.preprocess_field_value(field, value, self.FORMAT_XLSX)
|
|||
|
|
)
|
|||
|
|
if date_format and isinstance(value, datetime.datetime):
|
|||
|
|
cell.number_format = date_format
|
|||
|
|
yield cell
|
|||
|
|
|
|||
|
|
def write_csv_row(self, writer, row_dict):
|
|||
|
|
return writer.writerow(
|
|||
|
|
{
|
|||
|
|
field: self.preprocess_field_value(field, value, self.FORMAT_CSV)
|
|||
|
|
for field, value in row_dict.items()
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def get_heading(self, queryset, field):
|
|||
|
|
"""Get the heading label for a given field for a spreadsheet generated from queryset"""
|
|||
|
|
heading_override = self.export_headings.get(field)
|
|||
|
|
if heading_override:
|
|||
|
|
return force_str(heading_override)
|
|||
|
|
try:
|
|||
|
|
return capfirst(force_str(label_for_field(field, queryset.model)))
|
|||
|
|
except (AttributeError, FieldDoesNotExist):
|
|||
|
|
return force_str(field)
|
|||
|
|
|
|||
|
|
def stream_csv(self, queryset):
|
|||
|
|
"""Generate a csv file line by line from queryset, to be used in a StreamingHTTPResponse"""
|
|||
|
|
writer = csv.DictWriter(Echo(), fieldnames=self.list_export)
|
|||
|
|
yield writer.writerow(
|
|||
|
|
{field: self.get_heading(queryset, field) for field in self.list_export}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for item in queryset:
|
|||
|
|
yield self.write_csv_row(writer, self.to_row_dict(item))
|
|||
|
|
|
|||
|
|
def write_xlsx(self, queryset, output):
|
|||
|
|
"""Write an xlsx workbook from a queryset"""
|
|||
|
|
from openpyxl import Workbook
|
|||
|
|
|
|||
|
|
workbook = Workbook(write_only=True, iso_dates=True)
|
|||
|
|
|
|||
|
|
worksheet = workbook.create_sheet(title="Sheet1")
|
|||
|
|
worksheet.append(
|
|||
|
|
self.get_heading(queryset, field) for field in self.list_export
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
date_format = ExcelDateFormatter().get()
|
|||
|
|
for item in queryset:
|
|||
|
|
worksheet.append(
|
|||
|
|
self.generate_xlsx_row(
|
|||
|
|
worksheet, self.to_row_dict(item), date_format=date_format
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
workbook.save(output)
|
|||
|
|
|
|||
|
|
def write_xlsx_response(self, queryset):
|
|||
|
|
"""Write an xlsx file from a queryset and return a FileResponse"""
|
|||
|
|
output = BytesIO()
|
|||
|
|
self.write_xlsx(queryset, output)
|
|||
|
|
output.seek(0)
|
|||
|
|
|
|||
|
|
return FileResponse(
|
|||
|
|
output,
|
|||
|
|
as_attachment=True,
|
|||
|
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|||
|
|
filename=f"{self.get_filename()}.xlsx",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def write_csv_response(self, queryset):
|
|||
|
|
stream = self.stream_csv(queryset)
|
|||
|
|
|
|||
|
|
response = StreamingHttpResponse(stream, content_type="text/csv")
|
|||
|
|
response["Content-Disposition"] = 'attachment; filename="{}.csv"'.format(
|
|||
|
|
self.get_filename()
|
|||
|
|
)
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
def as_spreadsheet(self, queryset, spreadsheet_format):
|
|||
|
|
"""Return a response with a spreadsheet representing the exported data from queryset, in the format specified"""
|
|||
|
|
if spreadsheet_format == self.FORMAT_CSV:
|
|||
|
|
return self.write_csv_response(queryset)
|
|||
|
|
elif spreadsheet_format == self.FORMAT_XLSX:
|
|||
|
|
return self.write_xlsx_response(queryset)
|
|||
|
|
|
|||
|
|
def get_export_url(self, format):
|
|||
|
|
params = self.request.GET.copy()
|
|||
|
|
params["export"] = format
|
|||
|
|
return self.request.path + "?" + params.urlencode()
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def xlsx_export_url(self):
|
|||
|
|
return self.get_export_url("xlsx")
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def csv_export_url(self):
|
|||
|
|
return self.get_export_url("csv")
|
|||
|
|
|
|||
|
|
@cached_property
|
|||
|
|
def show_export_buttons(self):
|
|||
|
|
return bool(self.list_export)
|
|||
|
|
|
|||
|
|
@cached_property
|
|||
|
|
def header_more_buttons(self):
|
|||
|
|
buttons = super().header_more_buttons.copy()
|
|||
|
|
if self.show_export_buttons:
|
|||
|
|
buttons.append(
|
|||
|
|
Button(
|
|||
|
|
_("Download XLSX"),
|
|||
|
|
url=self.xlsx_export_url,
|
|||
|
|
icon_name="download",
|
|||
|
|
priority=90,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
buttons.append(
|
|||
|
|
Button(
|
|||
|
|
_("Download CSV"),
|
|||
|
|
url=self.csv_export_url,
|
|||
|
|
icon_name="download",
|
|||
|
|
priority=100,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return buttons
|