Как запустить загрузку и вывести ответ без удара по диску?

Итак, у меня есть форма проверки научных данных Excel-файла в django, которая работает хорошо. Она работает итеративно. Пользователи могут загружать файлы по мере накопления новых данных, которые они добавляют в свое исследование. DataValidationView каждый раз проверяет файлы и представляет пользователю отчет об ошибках, в котором перечислены проблемы в данных, которые он должен исправить.

Недавно мы поняли, что ряд ошибок (но не все) можно исправить автоматически, поэтому я работал над тем, чтобы создать копию файла с рядом исправлений. Поэтому мы переименовали страницу с формой "валидации" в "страницу создания заявки". Каждый раз, когда они загружают новый набор файлов, они должны получать отчет об ошибке, но также автоматически получать загруженный файл с рядом исправлений.

Только сегодня я узнал, что не существует способа одновременно отрисовать шаблон и запустить загрузку, что вполне логично. Однако я планировал не допускать попадания сгенерированного файла с исправлениями на диск.

Есть ли способ представить шаблон с ошибками и автоматически запустить загрузку без предварительного сохранения файла на диск?

Вот мой form_valid метод на данный момент (без запуска загрузки, но я уже начал создавать файл, прежде чем понял, что и загрузка, и рендеринг шаблона не будут работать):

    def form_valid(self, form):
        """
        Upon valid file submission, adds validation messages to the context of
        the validation page.
        """

        # This buffers errors associated with the study data
        self.validate_study()

        # This generates a dict representation of the study data with fixes and
        # removes the errors it fixed
        self.perform_fixes()

        # This sets self.results (i.e. the error report)
        self.format_validation_results_for_template()

        # HERE IS WHERE I REALIZED MY PROBLEM.  I WANTED TO CREATE A STREAM HERE
        # TO START A DOWNLOAD, BUT REALIZED I CANNOT BOTH PRESENT THE ERROR REPORT
        # AND START THE DOWNLOAD FOR THE USER

        return self.render_to_response(
            self.get_context_data(
                results=self.results,
                form=form,
                submission_url=self.submission_url,
            )
        )

До того, как я столкнулся с этой проблемой, я скомпилировал псевдокод для потоковой передачи файла... Это совершенно не проверено:

import pandas as pd
from django.http import HttpResponse
from io import BytesIO

def download_fixes(self):
    excel_file = BytesIO()
    xlwriter = pd.ExcelWriter(excel_file, engine='xlsxwriter')

    df_output = {}
    for sheet in self.fixed_study_data.keys():
        df_output[sheet] = pd.DataFrame.from_dict(dfs_dict[sheet])
        df_output[sheet].to_excel(xlwriter, sheet)

    xlwriter.save()
    xlwriter.close()

    # important step, rewind the buffer or when it is read() you'll get nothing
    # but an error message when you try to open your zero length file in Excel
    excel_file.seek(0)

    # set the mime type so that the browser knows what to do with the file
    response = HttpResponse(excel_file.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')

    # set the file name in the Content-Disposition header
    response['Content-Disposition'] = 'attachment; filename=myfile.xlsx'

    return response

Так что я думаю, что либо мне нужно:

  1. Сохранить файл на диске, а затем придумать способ заставить страницу результатов начать его загрузку
  2. Каким-то образом отправить данные, встроенные в шаблон результатов, и отправить их обратно через javascript, чтобы они превратились в поток загрузки файла
  3. Сохранить файл каким-то образом в памяти и запустить его загрузку из шаблона результатов?

Как лучше всего этого добиться?

ОБНОВЛЕННЫЕ МЫСЛИ:

Недавно я проделал простой трюк с файлом tsv, где я встроил содержимое файла в результирующий шаблон с кнопкой загрузки, которая использовала javascript для захвата innerHTML тегов вокруг данных и начала "загрузки".

Я подумал, что если я закодирую данные, то, вероятно, смогу сделать нечто подобное и с содержимым файла excel. Я мог бы закодировать его в base64.

Я просмотрел прошлые исследования. Самое большое из них было 115 кб. Скорее всего, этот размер вырастет на порядок, но пока 115 Кб - это потолок.

Я погуглил, чтобы найти способ встроить данные в шаблон, и получил это:

import base64
with open(image_path, "rb") as image_file:
    image_data = base64.b64encode(image_file.read()).decode('utf-8')
ctx["image"] = image_data
return render(request, 'index.html', ctx)

Недавно я играл с кодировкой base64 в javascript для некоторой несвязанной работы, что привело меня к мысли, что встраивание возможно. Я даже могу запускать его автоматически. У кого-нибудь есть какие-нибудь предостережения по поводу такого способа?

Для потомков, руководство по HTTP 1.1 multipart/byteranges Response реализованное в Django. Более подробную информацию о multipart/byteranges смотрите в RFC 7233.

Формат полезной нагрузки multipart/byteranges выглядит следующим образом:

HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5

--3d6b6a416f9b5
Content-Type: application/octet-stream
Content-Range: bytes 0-999/2000

<octet stream data 1>

--3d6b6a416f9b5
Content-Type: application/octet-stream
Content-Range: bytes 1000-1999/2000

<octet stream data 2>

--3d6b6a416f9b5
Content-Type: application/json
Content-Range: bytes 0-441/442

<json data>

--3d6b6a416f9b5
Content-Type: text/html 
Content-Range: bytes 0-543/544

<html string>
--3d6b6a416f9b5--

Вы поняли идею. Первые два - это одни и те же двоичные данные, разделенные на два потока, третий - строка JSON, отправленная в одном потоке, и четвертый - строка HTML, отправленная в одном потоке.

В вашем случае вы отправляете File вместе с шаблоном HTML.

from io import BytesIO, StringIO
from django.template.loader import render_to_string
from django.http import StreamingHttpResponse


def stream_generator(streams):
    boundary = "3d6b6a416f9b5"
    for stream in streams:
        if isinstance(stream, BytesIO):
            data = stream.getvalue()
            content_type = 'application/octet-stream'
        elif isinstance(stream, StringIO):
            data = stream.getvalue().encode('utf-8')
            content_type = 'text/html'
        else:
            continue
        
        stream_length = len(data)
        yield f'--{boundary}\r\n'
        yield f'Content-Type: {content_type}\r\n'
        yield f'Content-Range: bytes 0-{stream_length-1}/{stream_length}\r\n'
        yield f'\r\n'
        yield data
        yield f'\r\n'

    yield f'--{boundary}--\r\n'

def multi_stream_response(request):
    streams = [
        excel_file, # The File provided in the OP. It is a BytesIO object.
        StringIO(render_to_string('index.html', request=request))
    ]
    return StreamingHttpResponse(stream_generator(streams), content_type='multipart/byteranges; boundary=3d6b6a416f9b5')

Посмотрите этот пример [stackoverflow] по разбору multipart/byteranges на клиенте.

@Chukwujiobi_Canon ответ отличный, и масштабируемый, хотя мне потребовался целый день, чтобы почти заставить его работать, и это все еще не совсем так. Я ожидаю, что мне понадобится еще один день, чтобы довести его до совершенства, однако, учитывая, что мои файлы имеют размер менее 1 мб, я решил исследовать свою первоначальную идею: встроить содержимое файла в base64 в рендеринг страницы (скрытый), и запустить его загрузку автоматически в javascript.

На это ушло меньше часа, оно полностью функционально, и потребовалось совсем немного кода. Правда, часть кода была повторно использована при работе над другим решением.

Вот как я генерирую содержимое файла. Я включил метод, который принимает dict в стиле pandas и преобразует его в xlsxwriter (pip install xlsxwriter).

    import xlswriter

    def form_valid(self, form):

        # This buffers errors associated with the study data
        self.validate_study()

        # This generates a dict representation of the study data with fixes and
        # removes the errors it fixed
        self.perform_fixes()

        # This sets self.results (i.e. the error report)
        self.format_validation_results_for_template()

        study_stream = BytesIO()

        xlsxwriter = self.create_study_file_writer(study_stream)

        xlsxwriter.close()
        # Rewind the buffer so that when it is read(), you won't get an error about opening a zero-length file in Excel
        study_stream.seek(0)

        study_data = base64.b64encode(study_stream.read()).decode('utf-8')
        study_filename = self.animal_sample_filename
        if self.animal_sample_filename is None:
            study_filename = "study.xlsx"

        return self.render_to_response(
            self.get_context_data(
                results=self.results,
                form=form,
                submission_url=self.submission_url,
                study_data=study_data,
                study_filename=study_filename,
            ),
        )

    def create_study_file_writer(self, stream_obj: BytesIO):
        xlsxwriter = pd.ExcelWriter(stream_obj, engine='xlsxwriter')

        # This iterates over the desired order of the sheets and their columns
        for order_spec in self.get_study_sheet_column_display_order():

            sheet = order_spec[0]
            columns = order_spec[1]

            # Create a dataframe and add it as an excel object to an xlsxwriter sheet
            pd.DataFrame.from_dict(self.dfs_dict[sheet]).to_excel(
                excel_writer=xlsxwriter,
                sheet_name=sheet,
                columns=columns
            )

        return xlsxwriter

Это тег в шаблоне, в котором я отображаю данные

<pre style="display: none" id="output_study_file">{{study_data}}</pre>

Вот javascript, который "скачивает" файл:

document.addEventListener("DOMContentLoaded", function(){
    // If there is a study file that was produced
    if ( typeof study_file_content_tag !== "undefined" && study_file_content_tag ) {
        browserDownloadExcel('{{ study_filename }}', study_file_content_tag.innerHTML)
    }
})

function browserDownloadExcel (filename, base64_text) {
    const element = document.createElement('a');
    element.setAttribute(
        'href',
        'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,' + encodeURIComponent(base64_text)
    );
    element.setAttribute('download', filename);
    element.style.display = 'none';
    document.body.appendChild(element);
    element.click();
    document.body.removeChild(element);
}
Вернуться на верх