PDF file cannot be opened, when generated with Django and WeasyPrint
I recently began trying generating PDF reports with Django and Weasyprint, but I cannot make it work somehow.
I already have file upload, file download (even PDFs, but already generated ones that were uploaded), CSV generation and download, XLSX generation and download in my application, but I'm not able to return PDFs generated with WeasyPrint so far.
I want my users to be able to create reports, and they'll be saved for further new download.
So I created this basic model :
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)
Since I use service classes, I also did :
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
Note that for testing purpose, the template is very (too?) basic :
<html>
<head>
<meta http-equiv="Content-Type" content="text/html" charset="utf-8">
</head>
<body>
<article>
<h2>test report</h2>
</article>
</body>
</html>
Then the view to call this is as follow (I removed auth classes and so on on purpose to simplify code here, as well as intermediary layer that normally instantiate service classes) :
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
It does download the file, but once I try to open it in Google Chrome, it does the following, with no other kind of error whatsoever (even in my backend django container) :
Speaking of backend django container, my development backend container Dockerfile contains this :
FROM python:3.12.7
...
RUN apt-get install -y weasyprint
...
RUN pip install -r requirements/development.txt
Note that in my development.txt
I also have weasyprint==63.0
.
I have no error saying that I have a missing weasyprint dependency.
If some information is missing, tell me and I'll add it.
If someone has already had the problem, I would be grateful to have some help.
Thanks in advance.
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.
You can open the file, and thus dump the content in the response:
@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
But this is not a good idea: Django is not good with serving files. Typically you use a webserver like nginx for that, which has advanced streaming and caching mechanisms.
You can let the webserver like nginx serve the file, by returning a response with an X-Accel-Redirect
header:
@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
and then let Nginx handle the file itself with:
location /protected/ {
internal;
alias /path/to/directory/where/you/store/pdf/files/;
}
From the beginning I made the assomptions that :
- The generated PDF file was the issue
- Once I fixed that (or figured out it wasn't), I thought it was my way of returning it (which was strange since I already do something similar elsewhere in my code).
So I took the problem the other way and went to my frontend (a bit late to be honest) and I realized I forgot to put the responseType
as blob
... :facepalm:
BUT, after figuring out this, another problem was still there. To create my report (and return it alongside the response), I used an AXIOS POST request, which doesn't seem to work with { responseType: 'blob' }
. And I didn't know about it.
So I have 2 options :
- Make a GET request to create and download my PDF report in a single query.
- Make a POST request to create it and return the 'id' of the report, then a GET request to download it.
If anyone knows why I'm not able to make it work with a POST request, I'd be glad to learn.
Anyway, the result it this :
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
Frontend is Vue.js v2 :
methods: {
async createReport () {
this.loadingCreation = true
const response = await HelpdeskReportingService.createReport()
FileService.downloadFile(response)
this.loadingCreation = false
},
}
where createRepor
is :
static createReport = async () => {
try {
const response = await http.get('helpdesk/reports/create', { responseType: 'blob' })
return response
} catch (error) {
handleAxiosError(error)
}
}
AKX wrote
...you can use createObjectURL and create an ` element and simulate a click on it for the same effect.
and it's exactly what I do in my 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)
}
Hope it might help some people.