diff options
author | Marc Tamlyn <marc.tamlyn@gmail.com> | 2012-07-27 01:30:27 -0700 |
---|---|---|
committer | Marc Tamlyn <marc.tamlyn@gmail.com> | 2012-07-27 01:30:27 -0700 |
commit | bf9e3288d78ffebc959c0a898c4c05c2db6a383d (patch) | |
tree | 6eea45aa3a68fece4c9ae6add826eed50920c380 /wkhtmltopdf | |
parent | fab23c4fc12d667575064835c82146eb8f88f896 (diff) | |
parent | fd3c890e2ecd2b5448ee7ca65503c2b31ac92ddb (diff) | |
download | django-wkhtmltopdf-bf9e3288d78ffebc959c0a898c4c05c2db6a383d.tar.gz django-wkhtmltopdf-bf9e3288d78ffebc959c0a898c4c05c2db6a383d.tar.bz2 django-wkhtmltopdf-bf9e3288d78ffebc959c0a898c4c05c2db6a383d.zip |
Merge pull request #9 from ecometrica/master
Refactoring of django-wkhtmltopdf
Diffstat (limited to 'wkhtmltopdf')
-rw-r--r-- | wkhtmltopdf/_testproject/__init__.py | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/manage.py | 14 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/requirements.txt | 1 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/settings.py | 152 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/templates/footer.html | 2 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/templates/sample.html | 7 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/urls.py | 17 | ||||
-rw-r--r-- | wkhtmltopdf/subprocess.py | 44 | ||||
-rw-r--r-- | wkhtmltopdf/tests.py | 299 | ||||
-rw-r--r-- | wkhtmltopdf/utils.py | 173 | ||||
-rw-r--r-- | wkhtmltopdf/views.py | 311 |
11 files changed, 930 insertions, 90 deletions
diff --git a/wkhtmltopdf/_testproject/__init__.py b/wkhtmltopdf/_testproject/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/wkhtmltopdf/_testproject/__init__.py diff --git a/wkhtmltopdf/_testproject/manage.py b/wkhtmltopdf/_testproject/manage.py new file mode 100644 index 0000000..3e4eedc --- /dev/null +++ b/wkhtmltopdf/_testproject/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +import imp +try: + imp.find_module('settings') # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) + sys.exit(1) + +import settings + +if __name__ == "__main__": + execute_manager(settings) diff --git a/wkhtmltopdf/_testproject/requirements.txt b/wkhtmltopdf/_testproject/requirements.txt new file mode 100644 index 0000000..74c18fb --- /dev/null +++ b/wkhtmltopdf/_testproject/requirements.txt @@ -0,0 +1 @@ +Django==1.3.1 diff --git a/wkhtmltopdf/_testproject/settings.py b/wkhtmltopdf/_testproject/settings.py new file mode 100644 index 0000000..0454766 --- /dev/null +++ b/wkhtmltopdf/_testproject/settings.py @@ -0,0 +1,152 @@ +import os + +PROJECT_PATH = os.path.abspath(os.path.dirname(__file__)) + +# Django settings for testproject project. + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + # ('Your Name', 'your_email@example.com'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'test.db', # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'America/Chicago' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = '' + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# URL prefix for admin static files -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = '$)v)0$h)^c5!h2wms8*wn1c!7)7dp@qb87h7q)zecp2@$pnv=g' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'testproject.urls' + +TEMPLATE_DIRS = ( + os.path.join(PROJECT_PATH, 'templates') + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Uncomment the next line to enable the admin: + # 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + + 'wkhtmltopdf', +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/wkhtmltopdf/_testproject/templates/footer.html b/wkhtmltopdf/_testproject/templates/footer.html new file mode 100644 index 0000000..3ae09cb --- /dev/null +++ b/wkhtmltopdf/_testproject/templates/footer.html @@ -0,0 +1,2 @@ +MEDIA_URL = {{ MEDIA_URL }} +STATIC_URL = {{ STATIC_URL }} diff --git a/wkhtmltopdf/_testproject/templates/sample.html b/wkhtmltopdf/_testproject/templates/sample.html new file mode 100644 index 0000000..3dfbdbb --- /dev/null +++ b/wkhtmltopdf/_testproject/templates/sample.html @@ -0,0 +1,7 @@ +<html> + <head> + </head> + <body> + <h1>{{ title }}</h1> + </body> +</html> diff --git a/wkhtmltopdf/_testproject/urls.py b/wkhtmltopdf/_testproject/urls.py new file mode 100644 index 0000000..039e61a --- /dev/null +++ b/wkhtmltopdf/_testproject/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls.defaults import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'testproject.views.home', name='home'), + # url(r'^testproject/', include('testproject.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/wkhtmltopdf/subprocess.py b/wkhtmltopdf/subprocess.py new file mode 100644 index 0000000..d0abacd --- /dev/null +++ b/wkhtmltopdf/subprocess.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import + +from subprocess import * + + +# Provide Python 2.7's check_output() function. +try: + check_output +except NameError: + def check_output(*popenargs, **kwargs): + r"""Run command with arguments and return its output as a byte string. + + If the exit code was non-zero it raises a CalledProcessError. The + CalledProcessError object will have the return code in the returncode + attribute and output in the output attribute. + + The arguments are the same as for the Popen constructor. Example: + + >>> check_output(["ls", "-l", "/dev/null"]) + 'crw-rw-rw- 1 root root 1, 3 Oct 18 2007 /dev/null\n' + + The stdout argument is not allowed as it is used internally. + To capture standard error in the result, use stderr=STDOUT. + + >>> check_output(["/bin/sh", "-c", + ... "ls -l non_existent_file ; exit 0"], + ... stderr=STDOUT) + 'ls: non_existent_file: No such file or directory\n' + """ + if 'stdout' in kwargs: + raise ValueError('stdout argument not allowed, it will be overridden.') + process = Popen(stdout=PIPE, *popenargs, **kwargs) + output, unused_err = process.communicate() + retcode = process.poll() + if retcode: + cmd = kwargs.get("args") + if cmd is None: + cmd = popenargs[0] + error = CalledProcessError(retcode, cmd) + # Add the output attribute to CalledProcessError, which + # doesn't exist until Python 2.7. + error.output = output + raise error + return output diff --git a/wkhtmltopdf/tests.py b/wkhtmltopdf/tests.py index 5eb733f..3bdc481 100644 --- a/wkhtmltopdf/tests.py +++ b/wkhtmltopdf/tests.py @@ -1,13 +1,304 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +import os +import sys +import warnings + from django.test import TestCase +from django.test.client import RequestFactory + +from .subprocess import CalledProcessError +from .utils import (override_settings, + _options_to_args, template_to_temp_file, wkhtmltopdf) +from .views import (PDFResponse, PdfResponse, PDFTemplateResponse, + PDFTemplateView, PdfTemplateView) -from utils import template_to_temp_file class TestUtils(TestCase): + def setUp(self): + # Clear standard error + self._stderr = sys.stderr + sys.stderr = open(os.devnull, 'w') + + def tearDown(self): + sys.stderr = self._stderr + + def test_options_to_args(self): + self.assertEqual(_options_to_args(), []) + self.assertEqual(_options_to_args(heart=u'♥', verbose=True, + file_name='file-name'), + ['--file-name', 'file-name', + '--heart', u'♥', + '--verbose']) + + def test_wkhtmltopdf(self): + """Should run wkhtmltopdf to generate a PDF""" + title = 'A test template.' + temp_file = template_to_temp_file('sample.html', {'title': title}) + pdf_output = None + try: + # Standard call + pdf_output = wkhtmltopdf(pages=[temp_file]) + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Single page + pdf_output = wkhtmltopdf(pages=temp_file) + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Unicode + pdf_output = wkhtmltopdf(pages=[temp_file], title=u'♥') + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Invalid arguments + self.assertRaises(CalledProcessError, + wkhtmltopdf, pages=[]) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + def test_template_to_temp_file(self): """Should render a template to a temporary file.""" title = 'A test template.' temp_file = template_to_temp_file('sample.html', {'title': title}) - with open(temp_file, 'r') as f: - saved_content = f.read() - self.assertTrue(title in saved_content) + try: + with open(temp_file, 'r') as f: + saved_content = f.read() + self.assertTrue(title in saved_content) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + + +class TestViews(TestCase): + def test_pdf_response(self): + """Should generate the correct HttpResponse object and mimetype""" + # 404 + response = PDFResponse(content='', status=404) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content, '') + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertFalse(response.has_header('Content-Disposition')) + + content = '%PDF-1.4\n%%EOF' + # Without filename + response = PDFResponse(content=content) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, content) + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertFalse(response.has_header('Content-Disposition')) + + # With filename + response = PDFResponse(content=content, filename="nospace.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="nospace.pdf"') + response = PDFResponse(content=content, filename="one space.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="one space.pdf"') + response = PDFResponse(content=content, filename="4'5\".pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="4\'5.pdf"') + response = PDFResponse(content=content, filename=u"♥.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="?.pdf"') + + # Content-Type + response = PDFResponse(content=content, + content_type='application/x-pdf') + self.assertEqual(response['Content-Type'], 'application/x-pdf') + response = PDFResponse(content=content, + mimetype='application/x-pdf') + self.assertEqual(response['Content-Type'], 'application/x-pdf') + + def test_pdf_template_response(self): + """Test PDFTemplateResponse.""" + from django.conf import settings + + with override_settings( + MEDIA_URL='/media/', + MEDIA_ROOT='/tmp/media', + STATIC_URL='/static/', + STATIC_ROOT='/tmp/static', + TEMPLATE_CONTEXT_PROCESSORS=[ + 'django.core.context_processors.media', + 'django.core.context_processors.static', + ], + TEMPLATE_LOADERS=['django.template.loaders.filesystem.Loader'], + TEMPLATE_DIRS=[os.path.join(os.path.dirname(__file__), + '_testproject', 'templates')], + WKHTMLTOPDF_DEBUG=False, + ): + # Setup sample.html + template = 'sample.html' + context = {'title': 'Heading'} + request = RequestFactory().get('/') + response = PDFTemplateResponse(request=request, + template=template, + context=context) + self.assertEqual(response._request, request) + self.assertEqual(response.template_name, template) + self.assertEqual(response.context_data, context) + self.assertEqual(response.filename, None) + self.assertEqual(response.header_template, None) + self.assertEqual(response.footer_template, None) + self.assertEqual(response.cmd_options, {}) + self.assertFalse(response.has_header('Content-Disposition')) + + # Render to temporary file + tempfile = response.render_to_temporary_file(template) + tempfile.seek(0) + html_content = tempfile.read() + self.assertTrue(html_content.startswith('<html>')) + self.assertTrue('<h1>{title}</h1>'.format(**context) + in html_content) + + pdf_content = response.rendered_content + self.assertTrue(pdf_content.startswith('%PDF-')) + self.assertTrue(pdf_content.endswith('%%EOF\n')) + + # Footer + filename = 'output.pdf' + footer_template = 'footer.html' + cmd_options = {'title': 'Test PDF'} + response = PDFTemplateResponse(request=request, + template=template, + context=context, + filename=filename, + footer_template=footer_template, + cmd_options=cmd_options) + self.assertEqual(response.filename, filename) + self.assertEqual(response.header_template, None) + self.assertEqual(response.footer_template, footer_template) + self.assertEqual(response.cmd_options, cmd_options) + self.assertTrue(response.has_header('Content-Disposition')) + + tempfile = response.render_to_temporary_file(footer_template) + tempfile.seek(0) + footer_content = tempfile.read() + + media_url = 'MEDIA_URL = file://{0}/'.format(settings.MEDIA_ROOT) + self.assertTrue( + media_url in footer_content, + "{0!r} not in {1!r}".format(media_url, footer_content) + ) + + static_url = 'STATIC_URL = file://{0}/'.format(settings.STATIC_ROOT) + self.assertTrue( + static_url in footer_content, + "{0!r} not in {1!r}".format(static_url, footer_content) + ) + + pdf_content = response.rendered_content + self.assertTrue('\0'.join('{title}'.format(**cmd_options)) + in pdf_content) + + # Override settings + response = PDFTemplateResponse(request=request, + template=template, + context=context, + filename=filename, + footer_template=footer_template, + cmd_options=cmd_options, + override_settings={ + 'STATIC_URL': 'file:///tmp/s/' + }) + tempfile = response.render_to_temporary_file(footer_template) + tempfile.seek(0) + footer_content = tempfile.read() + + static_url = 'STATIC_URL = {0}'.format('file:///tmp/s/') + self.assertTrue( + static_url in footer_content, + "{0!r} not in {1!r}".format(static_url, footer_content) + ) + self.assertEqual(settings.STATIC_URL, '/static/') + + def test_pdf_template_view(self): + """Test PDFTemplateView.""" + with override_settings( + MEDIA_URL='/media/', + STATIC_URL='/static/', + TEMPLATE_CONTEXT_PROCESSORS=[ + 'django.core.context_processors.media', + 'django.core.context_processors.static', + ], + TEMPLATE_LOADERS=['django.template.loaders.filesystem.Loader'], + TEMPLATE_DIRS=[os.path.join(os.path.dirname(__file__), + '_testproject', 'templates')], + WKHTMLTOPDF_DEBUG=False, + ): + # Setup sample.html + template = 'sample.html' + filename = 'output.pdf' + view = PDFTemplateView.as_view(filename=filename, + template_name=template, + footer_template='footer.html') + + # As PDF + request = RequestFactory().get('/') + response = view(request) + self.assertEqual(response.status_code, 200) + response.render() + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="{0}"'.format(filename)) + self.assertTrue(response.content.startswith('%PDF-')) + self.assertTrue(response.content.endswith('%%EOF\n')) + + # As HTML + request = RequestFactory().get('/?as=html') + response = view(request) + self.assertEqual(response.status_code, 200) + response.render() + self.assertFalse(response.has_header('Content-Disposition')) + self.assertTrue(response.content.startswith('<html>')) + + # POST + request = RequestFactory().post('/') + response = view(request) + self.assertEqual(response.status_code, 405) + + def test_get_cmd_options(self): + # Default cmd_options + view = PDFTemplateView() + self.assertEqual(view.cmd_options, PDFTemplateView.cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + + # Instantiate with new cmd_options + cmd_options = {'orientation': 'landscape'} + view = PDFTemplateView(cmd_options=cmd_options) + self.assertEqual(view.cmd_options, cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + + # Update local instance of cmd_options + view = PDFTemplateView() + view.cmd_options.update(cmd_options) + self.assertEqual(view.cmd_options, cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + def test_deprecated(self): + """Should warn when using deprecated views.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PdfTemplateView() + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'PDFTemplateView' in str(w[0].message), + "'PDFTemplateView' not in {0!r}".format(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PdfResponse(None, None) + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'PDFResponse' in str(w[0].message), + "'PDFResponse' not in {0!r}".format(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PDFTemplateView().get_pdf_kwargs() + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'get_pdf_kwargs()' in str(w[0].message), + "'get_pdf_kwargs()' not in {0!r}".format(w[0].message)) diff --git a/wkhtmltopdf/utils.py b/wkhtmltopdf/utils.py index cf01f43..29e4ef6 100644 --- a/wkhtmltopdf/utils.py +++ b/wkhtmltopdf/utils.py @@ -1,19 +1,40 @@ +from __future__ import absolute_import + +from copy import copy +from itertools import chain from os import fdopen -from subprocess import Popen, PIPE, CalledProcessError +import os +import sys from tempfile import mkstemp +import urllib +import warnings from django.conf import settings from django.template import loader from django.utils.encoding import smart_str -WKHTMLTOPDF_CMD = getattr(settings, 'WKHTMLTOPDF_CMD', 'wkhtmltopdf') +from .subprocess import check_output + + +def _options_to_args(**options): + """Converts ``options`` into a string of command-line arguments.""" + flags = [] + for name in sorted(options): + value = options[name] + if value is None: + continue + flags.append('--' + name.replace('_', '-')) + if value is not True: + flags.append(unicode(value)) + return flags + def wkhtmltopdf(pages, output=None, **kwargs): """ Converts html to PDF using http://code.google.com/p/wkhtmltopdf/. pages: List of file paths or URLs of the html to be converted. - output: Optional output file path. + output: Optional output file path. If None, the output is returned. **kwargs: Passed to wkhtmltopdf via _extra_args() (See https://github.com/antialize/wkhtmltopdf/blob/master/README_WKHTMLTOPDF for acceptable args.) @@ -21,48 +42,146 @@ def wkhtmltopdf(pages, output=None, **kwargs): {'footer_html': 'http://example.com/foot.html'} becomes '--footer-html http://example.com/foot.html' - Where there is no value passed, use a blank string. e.g.: - {'disable_javascript': ''} + + Where there is no value passed, use True. e.g.: + {'disable_javascript': True} + becomes: + '--disable-javascript' + + To disable a default option, use None. e.g: + {'quiet': None'} becomes: - '--disable-javascript ' + '' example usage: - wkhtmltopdf(html_path="~/example.html", + wkhtmltopdf(pages=['/tmp/example.html'], dpi=300, - orientation="Landscape", - disable_javascript="") + orientation='Landscape', + disable_javascript=True) """ - - def _extra_args(**kwargs): - """Converts kwargs into a string of flags to be passed to wkhtmltopdf.""" - flags = '' - for k, v in kwargs.items(): - flags += ' --%s %s' % (k.replace('_', '-'), v) - return flags - - if not isinstance(pages, list): + if isinstance(pages, basestring): + # Support a single page. pages = [pages] - kwargs['quiet'] = '' - args = '%s %s %s %s' % (WKHTMLTOPDF_CMD, _extra_args(**kwargs), ' '.join(pages), output or '-') + if output is None: + # Standard output. + output = '-' - process = Popen(args, stdout=PIPE, shell=True) - stdoutdata, stderrdata = process.communicate() + # Default options: + options = getattr(settings, 'WKHTMLTOPDF_CMD_OPTIONS', None) + if options is None: + options = {'quiet': True} + else: + options = copy(options) + options.update(kwargs) - if process.returncode != 0: - raise CalledProcessError(process.returncode, args) + env = getattr(settings, 'WKHTMLTOPDF_ENV', None) + if env is not None: + env = dict(os.environ, **env) - if output is None: - output = stdoutdata + cmd = getattr(settings, 'WKHTMLTOPDF_CMD', 'wkhtmltopdf') + args = list(chain([cmd], + _options_to_args(**options), + list(pages), + [output])) + return check_output(args, stderr=sys.stderr, env=env) - return output def template_to_temp_file(template_name, dictionary=None, context_instance=None): """ Renders a template to a temp file, and returns the path of the file. """ + warnings.warn('template_to_temp_file is deprecated in favour of PDFResponse. It will be removed in version 1.', + PendingDeprecationWarning, 2) file_descriptor, tempfile_path = mkstemp(suffix='.html') with fdopen(file_descriptor, 'wt') as f: f.write(smart_str(loader.render_to_string(template_name, dictionary=dictionary, context_instance=context_instance))) return tempfile_path + +def content_disposition_filename(filename): + """ + Sanitize a file name to be used in the Content-Disposition HTTP + header. + + Even if the standard is quite permissive in terms of + characters, there are a lot of edge cases that are not supported by + different browsers. + + See http://greenbytes.de/tech/tc2231/#attmultinstances for more details. + """ + filename = filename.replace(';', '').replace('"', '') + return http_quote(filename) + + +def http_quote(string): + """ + Given a unicode string, will do its dandiest to give you back a + valid ascii charset string you can use in, say, http headers and the + like. + """ + if isinstance(string, unicode): + try: + import unidecode + string = unidecode.unidecode(string) + except ImportError: + string = string.encode('ascii', 'replace') + # Wrap in double-quotes for ; , and the like + return '"{0!s}"'.format(string.replace('\\', '\\\\').replace('"', '\\"')) + + +def pathname2fileurl(pathname): + """Returns a file:// URL for pathname. Handles OS-specific conversions.""" + return 'file://' + urllib.pathname2url(pathname) + + +try: + # From Django 1.4 + from django.conf import override_settings +except ImportError: + class override_settings(object): + """ + Acts as either a decorator, or a context manager. If it's a decorator it + takes a function and returns a wrapped function. If it's a contextmanager + it's used with the ``with`` statement. In either event entering/exiting + are called before and after, respectively, the function/block is executed. + """ + def __init__(self, **kwargs): + self.options = kwargs + self.wrapped = settings._wrapped + + def __enter__(self): + self.enable() + + def __exit__(self, exc_type, exc_value, traceback): + self.disable() + + def __call__(self, test_func): + from django.test import TransactionTestCase + if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase): + original_pre_setup = test_func._pre_setup + original_post_teardown = test_func._post_teardown + def _pre_setup(innerself): + self.enable() + original_pre_setup(innerself) + def _post_teardown(innerself): + original_post_teardown(innerself) + self.disable() + test_func._pre_setup = _pre_setup + test_func._post_teardown = _post_teardown + return test_func + else: + @wraps(test_func) + def inner(*args, **kwargs): + with self: + return test_func(*args, **kwargs) + return inner + + def enable(self): + override = copy(settings._wrapped) + for key, new_value in self.options.items(): + setattr(override, key, new_value) + settings._wrapped = override + + def disable(self): + settings._wrapped = self.wrapped diff --git a/wkhtmltopdf/views.py b/wkhtmltopdf/views.py index 164f94f..fff98c8 100644 --- a/wkhtmltopdf/views.py +++ b/wkhtmltopdf/views.py @@ -1,66 +1,283 @@ -import os +from __future__ import absolute_import + from re import compile +from tempfile import NamedTemporaryFile +import warnings from django.conf import settings -from django.contrib.sites.models import Site -from django.template.context import RequestContext -from django.template.response import HttpResponse +from django.http import HttpResponse +from django.template.response import TemplateResponse from django.views.generic import TemplateView -from wkhtmltopdf.utils import template_to_temp_file, wkhtmltopdf +from .utils import (content_disposition_filename, override_settings, + pathname2fileurl, wkhtmltopdf) class PDFResponse(HttpResponse): - def __init__(self, content, *args, **kwargs): - filename = kwargs.pop('filename', None) - super(PDFResponse, self).__init__(content, 'application/pdf', *args, **kwargs) + """HttpResponse that sets the headers for PDF output.""" + + def __init__(self, content, mimetype=None, status=200, + content_type=None, filename=None, *args, **kwargs): + + if content_type is None: + content_type = 'application/pdf' + + super(PDFResponse, self).__init__(content=content, + mimetype=mimetype, + status=status, + content_type=content_type) + self.set_filename(filename) + + def set_filename(self, filename): + self.filename = filename if filename: + filename = content_disposition_filename(filename) header_content = 'attachment; filename={0}'.format(filename) - self.__setitem__('Content-Disposition', header_content) + self['Content-Disposition'] = header_content + else: + del self['Content-Disposition'] class PdfResponse(PDFResponse): def __init__(self, content, filename): - warning = '''PdfResponse is deprecated in favour of PDFResponse. It will be removed in version 1.''' - raise PendingDeprecationWarning(warning) - super(PdfResponse, self).__init__(content, filename) + warnings.warn('PdfResponse is deprecated in favour of PDFResponse. It will be removed in version 1.', + PendingDeprecationWarning, 2) + super(PdfResponse, self).__init__(content, filename=filename) + + +class PDFTemplateResponse(TemplateResponse, PDFResponse): + """Renders a Template into a PDF using wkhtmltopdf""" + + def __init__(self, request, template, context=None, mimetype=None, + status=None, content_type=None, current_app=None, + filename=None, header_template=None, footer_template=None, + cmd_options=None, override_settings=None, + *args, **kwargs): + + super(PDFTemplateResponse, self).__init__(request=request, + template=template, + context=context, + mimetype=mimetype, + status=status, + content_type=content_type, + current_app=None, + *args, **kwargs) + self.set_filename(filename) + + self.header_template = header_template + self.footer_template = footer_template + + if cmd_options is None: + cmd_options = {} + self.cmd_options = cmd_options + + self.override_settings = override_settings + + def render_to_temporary_file(self, template_name, mode='w+b', bufsize=-1, + suffix='', prefix='tmp', dir=None, + delete=True): + template = self.resolve_template(template_name) + + # Since many things require a sensible settings.MEDIA_URL and + # settings.STATIC_URL, including TEMPLATE_CONTEXT_PROCESSORS; + # the settings themselves need to be overridden when rendering. + # + # This allows django-wkhtmltopdf to play nicely with the + # staticfiles app, for instance. + with override_settings(**self.get_override_settings()): + context = self.resolve_context(self.context_data) + content = template.render(context) + + tempfile = NamedTemporaryFile(mode=mode, bufsize=bufsize, + suffix=suffix, prefix=prefix, + dir=dir, delete=delete) + try: + tempfile.write(content) + tempfile.flush() + return tempfile + except: + # Clean-up tempfile if an Exception is raised. + tempfile.close() + raise + + def convert_to_pdf(self, filename, + header_filename=None, footer_filename=None): + cmd_options = self.cmd_options.copy() + # Clobber header_html and footer_html only if filenames are + # provided. These keys may be in self.cmd_options as hardcoded + # static files. + if header_filename is not None: + cmd_options['header_html'] = header_filename + if footer_filename is not None: + cmd_options['footer_html'] = footer_filename + return wkhtmltopdf(pages=[filename], **cmd_options) + + @property + def rendered_content(self): + """Returns the freshly rendered content for the template and context + described by the PDFResponse. + + This *does not* set the final content of the response. To set the + response content, you must either call render(), or set the + content explicitly using the value of this property. + """ + debug = getattr(settings, 'WKHTMLTOPDF_DEBUG', settings.DEBUG) + + input_file = header_file = footer_file = None + header_filename = footer_filename = None + + try: + input_file = self.render_to_temporary_file( + template_name=self.template_name, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + + if self.header_template: + header_file = self.render_to_temporary_file( + template_name=self.header_template, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + header_filename = header_file.name + + if self.footer_template: + footer_file = self.render_to_temporary_file( + template_name=self.footer_template, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + footer_filename = footer_file.name + + return self.convert_to_pdf(filename=input_file.name, + header_filename=header_filename, + footer_filename=footer_filename) + finally: + # Clean up temporary files + for f in filter(None, (input_file, header_file, footer_file)): + f.close() + + def get_override_settings(self): + """Returns a dictionary of settings to override for response_class""" + overrides = { + 'MEDIA_ROOT': settings.MEDIA_ROOT, + 'MEDIA_URL': settings.MEDIA_URL, + 'STATIC_ROOT': settings.STATIC_ROOT, + 'STATIC_URL': settings.STATIC_URL, + } + if self.override_settings is not None: + overrides.update(self.override_settings) + + has_scheme = compile(r'^[^:/]+://') + + # If MEDIA_URL doesn't have a scheme, we transform it into a + # file:// URL based on MEDIA_ROOT. + urls = [('MEDIA_URL', 'MEDIA_ROOT'), + ('STATIC_URL', 'STATIC_ROOT')] + for url, root in urls: + if not has_scheme.match(overrides[url]): + overrides[url] = pathname2fileurl(overrides[root]) + if not overrides[url].endswith('/'): + overrides[url] += '/' + + return overrides class PDFTemplateView(TemplateView): + """Class-based view for HTML templates rendered to PDF.""" + + # Filename for downloaded PDF. If None, the response is inline. filename = 'rendered_pdf.pdf' - footer_template = None + + # Filenames for the content, header, and footer templates. + template_name = None header_template = None - orientation = 'portrait' - margin_bottom = 0 - margin_left = 0 - margin_right = 0 - margin_top = 0 - response = PDFResponse - _tmp_files = None + footer_template = None + + # TemplateResponse classes for PDF and HTML + response_class = PDFTemplateResponse + html_response_class = TemplateResponse + + # Command-line options to pass to wkhtmltopdf + cmd_options = { + # 'orientation': 'portrait', + # 'collate': True, + # 'quiet': None, + } def __init__(self, *args, **kwargs): - self._tmp_files = [] super(PDFTemplateView, self).__init__(*args, **kwargs) - def get(self, request, context_instance=None, *args, **kwargs): - if request.GET.get('as', '') == 'html': - return super(PDFTemplateView, self).get(request, *args, **kwargs) - - if context_instance: - self.context_instance = context_instance - else: - self.context_instance = RequestContext(request, self.get_context_data(**kwargs)) + # Copy self.cmd_options to prevent clobbering the class-level object. + self.cmd_options = self.cmd_options.copy() - page_path = template_to_temp_file(self.get_template_names(), self.get_context_data(), self.context_instance) - pdf_kwargs = self.get_pdf_kwargs() - output = wkhtmltopdf(page_path, **pdf_kwargs) - if self._tmp_files: - map(os.remove, self._tmp_files) - return self.response(output, filename=self.get_filename()) + def get(self, request, *args, **kwargs): + response_class = self.response_class + try: + if request.GET.get('as', '') == 'html': + # Use the html_response_class if HTML was requested. + self.response_class = self.html_response_class + return super(PDFTemplateView, self).get(request, + *args, **kwargs) + finally: + # Remove self.response_class + self.response_class = response_class def get_filename(self): return self.filename + def get_cmd_options(self): + return self.cmd_options + + def get_pdf_kwargs(self): + warnings.warn('PDFTemplateView.get_pdf_kwargs() is deprecated in favour of get_cmd_options(). It will be removed in version 1.', + PendingDeprecationWarning, 2) + return self.get_cmd_options() + + def render_to_response(self, context, **response_kwargs): + """ + Returns a PDF response with a template rendered with the given context. + """ + filename = response_kwargs.pop('filename', None) + cmd_options = response_kwargs.pop('cmd_options', None) + override_settings = response_kwargs.pop('override_settings', None) + + if issubclass(self.response_class, PDFTemplateResponse): + if filename is None: + filename = self.get_filename() + + if cmd_options is None: + cmd_options = self.get_cmd_options() + + return super(PDFTemplateView, self).render_to_response( + context=context, filename=filename, + header_template=self.header_template, + footer_template=self.footer_template, + cmd_options=cmd_options, override_settings=override_settings, + **response_kwargs + ) + else: + return super(PDFTemplateView, self).render_to_response( + context=context, + **response_kwargs + ) + + +class PdfTemplateView(PDFTemplateView): #TODO: Remove this in v1.0 + orientation = 'portrait' + margin_bottom = 0 + margin_left = 0 + margin_right = 0 + margin_top = 0 + + def __init__(self, *args, **kwargs): + warnings.warn('PdfTemplateView is deprecated in favour of PDFTemplateView. It will be removed in version 1.', + PendingDeprecationWarning, 2) + super(PdfTemplateView, self).__init__(*args, **kwargs) + + def get_cmd_options(self): + return self.get_pdf_kwargs() + def get_pdf_kwargs(self): kwargs = { 'margin_bottom': self.margin_bottom, @@ -69,28 +286,4 @@ class PDFTemplateView(TemplateView): 'margin_top': self.margin_top, 'orientation': self.orientation, } - if self.header_template: - kwargs['header_html'] = template_to_temp_file(self.header_template, self.get_context_data(), self.context_instance) - self._tmp_files.append(kwargs['header_html']) - if self.footer_template: - kwargs['footer_html'] = template_to_temp_file(self.footer_template, self.get_context_data(), self.context_instance) - self._tmp_files.append(kwargs['footer_html']) return kwargs - - def get_context_data(self, **kwargs): - context = super(PDFTemplateView, self).get_context_data(**kwargs) - - match_full_url = compile(r'^https?://') - if not match_full_url.match(settings.STATIC_URL): - context['STATIC_URL'] = 'http://' + Site.objects.get_current().domain + settings.STATIC_URL - if not match_full_url.match(settings.MEDIA_URL): - context['MEDIA_URL'] = 'http://' + Site.objects.get_current().domain + settings.MEDIA_URL - - return context - - -class PdfTemplateView(PDFTemplateView): #TODO: Remove this in v1.0 - def as_view(cls, **initkwargs): - warning = '''PdfTemplateView is deprecated in favour of PDFTemplateView. It will be removed in version 1.''' - raise PendingDeprecationWarning(warning) - return super(PdfTemplateView, cls).as_view(**initkwargs) |