Невозможно открыть PDF-файл при создании с помощью Django и WeasyPrint

Недавно я начал пробовать генерировать PDF-отчеты с помощью Django и Weasyprint, но никак не могу заставить их работать.

В моем приложении уже есть загрузка файлов, скачивание файлов (даже PDF, но уже сгенерированных, которые были загружены), генерация и скачивание CSV, генерация и скачивание XLSX, но я пока не могу вернуть PDF, сгенерированные с помощью WeasyPrint.

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

Итак, я создал эту базовую модель :

from django.db import models

class HelpdeskReport(models.Model):
    class Meta:
        app_label = "helpdesk"
        db_table = "helpdesk_report"
        verbose_name = "Helpdesk Report"
        verbose_name_plural = "Helpdesk Reports"
        default_permissions = ["view", "add", "delete"]
        permissions = [
            ("download_helpdeskreport", "Can download Heldpesk Report"),
        ]

    def _report_path(instance, filename) -> str:
        return f"helpdesk/reports/{instance.id}"

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=100, unique=True)
    file = models.FileField(upload_to=_report_path)

Поскольку я использую сервисные классы, я также сделал :

import uuid
from io import BytesIO
from django.core.files import File
from django.template.loader import render_to_string
from weasyprint import HTML

class HeldpeskReportService:
    def create_report(self) -> HelpdeskReport:
        report_name = f"helpdesk_report_{uuid.uuid4().hex}.pdf"
        html_source = render_to_string(template_name="reports/global_report.html")
        html_report = HTML(string=html_source)
        pdf_report = html_report.write_pdf()
        # print("check : ", pdf_report[:5])  # It gives proper content beginning (PDF header)
        pdf_bytes = BytesIO(pdf_report)
        pdf_file = File(file=pdf_file, name=report_name)
        report = HelpdeskReport(name=report_name, file=pdf_file)
        report.full_clean()
        report.save()
        return report

Обратите внимание, что для целей тестирования шаблон очень (слишком?) простой :

<html>
<head>
  <meta http-equiv="Content-Type" content="text/html" charset="utf-8">
</head>
<body>
  <article>
    <h2>test report</h2>
  </article>
</body>
</html>

Тогда представление для вызова этого выглядит следующим образом (я специально удалил классы auth и т.д., чтобы упростить код, а также промежуточный слой, который обычно инстанцирует классы сервисов) :

from django.http import HttpResponse
from rest_framework.decorators import api_view
from rest_framework.request import Request

@api_view(http_method_names=["POST"])
def create_report(request: Request) -> HttpResponse:
    """
    Create a HelpdeskReport.
    """
    report = HelpdeskReportService().create_report()
    response = HttpResponse(content=report.file)
    response["Content-Type"] = "application/pdf"
    response["Content-Disposition"] = f"attachment; filename={report.name}"
    return response

Он загружает файл, но когда я пытаюсь открыть его в Google Chrome, он делает следующее, без каких-либо других ошибок (даже в моем внутреннем контейнере django) :

enter image description here

Говоря о бэкенд-контейнере django, мой Dockerfile для бэкенд-контейнера разработки содержит следующее :

FROM python:3.12.7
...
RUN apt-get install -y weasyprint
...
RUN pip install -r requirements/development.txt

Обратите внимание, что в моем development.txt у меня также есть weasyprint==63.0.

У меня нет ошибки, говорящей о том, что у меня отсутствует зависимость weasyprint.

Если какой-то информации не хватает, скажите мне, и я добавлю ее.

Если кто-то уже сталкивался с этой проблемой, я буду благодарен за помощь.

Заранее спасибо.

report.file will be a FieldFile [Django- doc] (not to be confused with a FileField model field [Django-doc]), so if you use this as content, it will call str(..) on it, and thus write the name of the filepath.

Вы можете открыть файл и таким образом выгрузить его содержимое в ответ:

@api_view(http_method_names=['POST'])
def create_report(request: Request) -> HttpResponse:
    report = HelpdeskReportService().create_report()
    with report.file.open(mode='rb') as fh:
        response = HttpResponse(content=fh.read())
    response['Content-Type'] = 'application/pdf'
    response['Content-Disposition'] = f"attachment; filename={report.name}"
    return response

Но это не хорошая идея: Django не умеет обслуживать файлы. Обычно для этого используется веб-сервер типа nginx, который имеет развитые механизмы потоковой передачи и кэширования.

Вы можете позволить веб-серверу, например nginx, обслуживать файл, возвращая ответ с заголовком X-Accel-Redirect:

@api_view(http_method_names=['POST'])
def create_report(request: Request) -> HttpResponse:
    report = HelpdeskReportService().create_report()
    response = HttpResponse()
    response['Content-Type'] = 'application/pdf'
    response['X-Accel-Redirect'] = f'/protected/{report.file.path}'
    response['Content-Disposition'] = f"attachment; filename={report.name}"
    return response

и затем позвольте Nginx самому обработать файл с помощью:

location /protected/ {
  internal;
  alias   /path/to/directory/where/you/store/pdf/files/;
}

С самого начала я утверждал, что :

  1. Проблема была в сгенерированном PDF-файле
  2. .
  3. После того, как я исправил это (или выяснил, что это не так), я подумал, что это мой способ возврата (что было странно, так как я уже делал что-то подобное в другом месте моего кода).

Так что я решил проблему с другой стороны и пошел на фронтенд (немного поздновато, если честно) и понял, что забыл поставить responseType как blob ... :facepalm:

Но после того, как я разобрался с этим, осталась еще одна проблема. Чтобы создать отчет (и вернуть его вместе с ответом), я использовал запрос AXIOS POST, который, похоже, не работает с { responseType: 'blob' }. И я не знал об этом.

Итак, у меня есть 2 варианта:

  1. Сделайте GET-запрос, чтобы создать и загрузить мой PDF-отчет в одном запросе.
  2. Сделайте POST-запрос, чтобы создать его и вернуть 'id' отчета, а затем GET-запрос, чтобы загрузить его.

Если кто-нибудь знает, почему я не могу заставить его работать с POST-запросом, я был бы рад узнать.

В любом случае, результат таков:

def create_report(self) -> HelpdeskReport:
    report_name = f"helpdesk_report_{uuid.uuid4()}.pdf"
    html_source = render_to_string(template_name="reports/global_report.html")
    pdf_report = HTML(string=html_source).write_pdf()
    pdf_bytes = BytesIO(pdf_report)
    pdf_file = File(file=pdf_bytes, name=report_name)
    report = HelpdeskReport(name=report_name, file=pdf_file)
    report.full_clean()
    report.save()
    return report
@api_view(http_method_names=["GET"])
@authentication_classes([...])
@permission_classes([...])
def create_report(request: Request) -> HttpResponse:
    # Simplified version here :
    report = HelpdeskReportService().create_report()
    response = HttpResponse(content=report.file)
    response["Content-Type"] = "application/pdf"
    response["Content-Disposition"] = f"attachment; filename={report.name}"
    return response

Фронтенд - Vue.js v2 :

methods: {
  async createReport () {
    this.loadingCreation = true
    const response = await HelpdeskReportingService.createReport()
    FileService.downloadFile(response)
    this.loadingCreation = false
  },
}

где createRepor это :

static createReport = async () => {
  try {
    const response = await http.get('helpdesk/reports/create', { responseType: 'blob' })
    return response
  } catch (error) {
    handleAxiosError(error)
  }
}

AKX написал

... Вы можете использовать createObjectURL и создать элемент ` и имитировать щелчок на нем для того же эффекта.

и это именно то, что я делаю в своем FileService :

static downloadFile (httpResponse) {
  const filename = this.getFilename(httpResponse)
  const file = httpResponse.data
  const blob = new Blob([file], { type: file.type })
  const link = document.createElement('a')
  link.href = window.URL.createObjectURL(blob)
  link.download = filename
  link.click()
  URL.revokeObjectURL(link.href)
}

Надеюсь, это поможет некоторым людям.

Вернуться на верх