├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── documents.py ├── maker.py ├── models.py └── templates └── docmaker └── base.html /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python ### 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Blueshoe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Docmaker 2 | A Django app that makes creating PDFs out of HTML-Templates and with custom context-data easy. 3 | 4 | A url for the PDFs download to the browser is generated automatically. 5 | 6 | 7 | # Installation & Requirements 8 | Copy this app into your Django project and add it to the `INSTALLED_APPS`. 9 | 10 | [`WeasyPrint`](https://pypi.python.org/pypi/WeasyPrint/) is used to generate the PDF, install it via pip: 11 | ``` 12 | pip install weasyprint 13 | ``` 14 | 15 | Add the docmaker urls to your `urls.py` in the Django-root. This looks similar to this: 16 | ``` 17 | urls.py 18 | from docmaker.maker import docmaker 19 | urlpatterns = [ 20 | url(_(r'^demo/'), include('app.demo', namespace='demo')), 21 | url(_(r'^account/'), include('app.account', namespace='account')), 22 | ... 23 | url(r'^pdf/', docmaker.urls), 24 | ] 25 | ``` 26 | It is tested with Python 2.7 and Django 1.8 27 | 28 | # Usage 29 | The approach is that you can add a `documents.py` module to any of your existing Django apps. Within this module you can define the PDF documents that you wish to be able to create. 30 | 31 | ### Declare your Documents 32 | A PDF document represents a certain PDF downloadable content. It provides all functionality to generate a pdf upon HTTP request. 33 | Internally it utilizes WeasyPrint - a HTML/CSS rendering engine for the pdf output with no command-line tool dependencies (such as wkhtmltopdf). 34 | 35 | A PDF document is a python class that inherits from `PDFDocument` and is placed inside the `documents.py` module in a given app. 36 | It is registered to the docmaker using `docmaker.register(MyDocument)` 37 | 38 | #### Attributes 39 | * `name`: Slugified name of the PDF document, eg. 'my-document'. 40 | * `url_name`: Slugified name that will be used in the url, eg 'my-document'. This is used to reference the document as a view in eg. Django's {% url %} template-tag. 41 | * `template_name`: (required unless you override `get_template()`) eg. `myapp/templates/myapp/pdf/my-document.html`. 42 | * `filename`: (optional) Filename of the generated PDF file, eg. 'account.pdf'. 43 | * `css_files`: (optional) List of css files that should be additionally used when rendering the template. 44 | * `media_type`: (optional) The media type to use for `@media`. 45 | * `login_required`: (optional) Boolean whether a login is required to request the document. 46 | 47 | #### DocumentMeta 48 | Set the meta data that will be attached to the pdf file 49 | 50 | * `title` 51 | * `author` 52 | * `description` 53 | * `keywords` (String) 54 | * `generator` 55 | * `created` 56 | * `modified` 57 | This data can also be overridden in `get_context_data`. 58 | 59 | #### Hooks 60 | To be flexible there are a couple of hooks you can extend or override in your document: 61 | 62 | * `def get(self, request)` 63 | * `def pre_create(self)` 64 | * `def get_template(self)` 65 | * `def get_context_data(self, **kwargs)` 66 | * `def get_document(self, html_str)` 67 | * `def get_filename(self)` 68 | 69 | Most importantly, use `get_context_data` to pass data to your template. 70 | 71 | #### Complete example of a PDFDocument 72 | ``` 73 | documents.py 74 | # -*- coding: utf-8 -*- 75 | from django.http import Http404 76 | from docmaker.documents import PDFDocument 77 | from docmaker.maker import docmaker 78 | 79 | 80 | class AccountDocument(PDFDocument): 81 | name = 'account-document' 82 | url_name = 'account-document' 83 | 84 | username = None # Will be passed as a GET parameter 85 | 86 | class DocumentMeta: 87 | title = 'Account Overview' 88 | author = 'Blueshoe' 89 | description = '' 90 | keywords = '' 91 | generator = '' 92 | created = '' 93 | modified = '' 94 | 95 | def pre_create(self): 96 | # Fetch some data from the GET request 97 | username = self.request.GET.get('username') 98 | if not username: 99 | raise Http404 100 | self.username = username 101 | 102 | def get_filename(self): 103 | return 'Account-Overview-{}.pdf'.format(self.username) 104 | 105 | def get_template(self): 106 | # Use any logic to choose between possible templates 107 | if self.is_premium_user(self.username): 108 | return 'app/pdf/account_premium.html' 109 | else: 110 | return 'app/pdf/account_simple.html' 111 | 112 | def get_context_data(self, **kwargs): 113 | ctx = super(AccountDocument, self).get_context_data(**kwargs) 114 | if self.is_premium_user(self.username): 115 | ctx['title'] = 'Replaced PDF title because he\'s a premium user' 116 | ctx['any_value'] = 'Dummy Value' 117 | return ctx 118 | 119 | def is_premium_user(self, username): 120 | return True 121 | 122 | 123 | docmaker.register(AccountDocument) 124 | ``` 125 | 126 | Take a look at the next two sections on how to create this document. 127 | 128 | ### Use it in a template 129 | As stated are `PDFDocuments` well-known `TemplateViews`. This means we can use it's URL that docmaker is generating automatically and for exmaple request the PDF's via a link in a Template: 130 | ``` 131 | 132 | ``` 133 | 134 | ### Use it in views 135 | You can return a PDF as the response to a given view, like the following: 136 | ``` 137 | def form_valid(self, form): 138 | ... 139 | return HttpResponse(reverse('app:documents:my-document')+'?username={}'.format(self.object.username)) 140 | ``` 141 | 142 | ### Creating HTML templates for your PDFs 143 | Just write the PDF in HTML and CSS and make use of Django's template engine as you're used to it. For Details on some constraints, you might refer to the [WeasyPrint Documentation](http://weasyprint.org/docs/) . 144 | It's recommended to add the templates inside a pdf folder in your apps template/app/ folder to keep the pdf templates separated from the ones of views. Eg. `myapp/templates/myapp/pdf/my-document.html` 145 | 146 | # Authors 147 | * [Michael Schilonka](https://github.com/Schille) -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blueshoe/Django-Docmaker/61f4a1f7cab0b493862c2160437c991c6775edf4/__init__.py -------------------------------------------------------------------------------- /documents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from django.contrib.staticfiles import finders 5 | from django.core.exceptions import PermissionDenied 6 | from django.http import HttpResponse 7 | from django.template.loader import render_to_string 8 | from django.views.generic.base import TemplateView 9 | from weasyprint import CSS, HTML 10 | 11 | logger = logging.getLogger('docmaker') 12 | 13 | 14 | class PDFDocument(TemplateView): 15 | """ 16 | A PDF document represents a certain PDF downloadable content. It provides all functionality to generate a 17 | pdf upon HTTP request. Internally it utilizes WeasyPrint - a HTML/CSS rendering engine for the pdf output with 18 | no command-line tool dependencies (such as wkhtmltopdf). 19 | """ 20 | name = None 21 | url_name = None 22 | filename = None 23 | template_name = None 24 | css_files = [] 25 | media_type = 'print' 26 | login_required = True 27 | 28 | class DocumentMeta: 29 | title = 'MyDocument' 30 | author = 'No Author' 31 | description = '' 32 | keywords = '' 33 | generator = 'WeasyPrint Document PDF Writer' 34 | created = '' 35 | modified = '' 36 | 37 | def get(self, request): 38 | # first, check if this report is for authenticated users only 39 | if self.login_required: 40 | if not request.user.is_authenticated: 41 | # raise a permission denied 42 | raise PermissionDenied 43 | 44 | # call the pre create hook 45 | self.pre_create() 46 | if request.GET.get('html') is not None: 47 | return HttpResponse(self._render(as_html=True)) 48 | else: 49 | # create a report 50 | b_content = self._render() 51 | # Create the HttpResponse object with the appropriate PDF headers and content 52 | response = HttpResponse(b_content, content_type='application/pdf') 53 | # This cookie is needed if the download is done 'asynchronously' via jQueryFileDownload.js 54 | # It 'tells' the download-function that the download was done. Check out Return-Creation on how this is used 55 | response.set_cookie('fileDownload', 'true') 56 | if self.get_filename(): 57 | response['Content-Disposition'] = 'attachment; filename="{filename}.pdf"'.format( 58 | filename=self.get_filename()) 59 | else: 60 | response['Content-Disposition'] = 'attachment; filename="{filename}.pdf"'.format( 61 | filename=self.__class__.__name__) 62 | return response 63 | 64 | def pre_create(self): 65 | """ 66 | The pre-create hook is called before the sheet gets created. It can be used for security checks, fetching data 67 | or updating the database (e.g. a counter). 68 | """ 69 | pass 70 | 71 | def get_template(self): 72 | """ 73 | This function is called to select the template for this request. It is then searched by the template finder. 74 | :return: The template name (String) 75 | """ 76 | return self.template_name 77 | 78 | def get_context_data(self, **kwargs): 79 | """ 80 | Sets up the context for the selected template. 81 | :param kwargs: 82 | :return: The context instance (Context) 83 | """ 84 | # ctx = RequestContext(self.request) 85 | 86 | ctx = {} 87 | ctx['title'] = self.DocumentMeta.title 88 | ctx['author'] = self.DocumentMeta.author 89 | ctx['description'] = self.DocumentMeta.description 90 | ctx['keywords'] = self.DocumentMeta.keywords 91 | ctx['generator'] = self.DocumentMeta.generator 92 | ctx['created'] = self.DocumentMeta.modified 93 | ctx['modified'] = self.DocumentMeta.modified 94 | return ctx 95 | 96 | def get_document(self, html_str): 97 | """ 98 | 99 | This hook provides the feature to retrieve the resulting WeasyPrint HTML instance for further modification. 100 | :param html_str: The rendered HTML string 101 | :return: a WeasyPrint HTML instance 102 | """ 103 | try: 104 | base_url = self.request.build_absolute_uri() 105 | except AttributeError: 106 | base_url = None 107 | return HTML(string=html_str, media_type=self.media_type, base_url=base_url) 108 | 109 | def get_filename(self): 110 | """ 111 | This function returns the filename for this request. It can be used to generate filenames based on the request. 112 | :return: The filename (String) 113 | """ 114 | return self.filename or self.__class__.__name__ + '.pdf' 115 | 116 | def _render(self, as_html=False): 117 | # render all information to a binary output 118 | template_name = self.get_template() 119 | ctx = self.get_context_data() 120 | report_html = render_to_string(template_name, ctx) 121 | 122 | if as_html: 123 | return report_html 124 | else: 125 | additional_css = [] 126 | for css in self.css_files: 127 | f = finders.find(css) 128 | if f: 129 | additional_css.append(CSS(filename=f)) 130 | else: 131 | # silently fail in case a css file could not be found 132 | logger.warn('Could not find css file {}'.format(css)) 133 | 134 | html_doc = self.get_document(report_html) 135 | 136 | # create the binary data 137 | b_doc = html_doc.render(stylesheets=additional_css) 138 | return b_doc.write_pdf() 139 | 140 | 141 | -------------------------------------------------------------------------------- /maker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from docmaker.documents import PDFDocument 5 | 6 | 7 | class AlreadyRegisteredException(Exception): 8 | pass 9 | 10 | 11 | class NotRegisteredError(Exception): 12 | pass 13 | 14 | 15 | class DocMaker(object): 16 | """ 17 | The DocMaker class provides a wrapper functionality to print PDF using WeasyPrint. It also poses the view endpoints 18 | to download the documents to the client. Special input is to be handled using GET parameters. 19 | """ 20 | 21 | def __init__(self, name='docmaker'): 22 | # registry for the documents to be available 23 | self._registry = {} 24 | self.name = name 25 | 26 | def register(self, document_klass, name=None): 27 | """ 28 | Register a document to the pool. 29 | :param document_klass: The document class, musst have 'Document' as super type 30 | :param name: The name of this document 31 | """ 32 | 33 | # Check if this document is already registered 34 | # there are 3 ways to register a document: explicit name during registration, by teh name property or class name 35 | if name: 36 | if name in self._registry: 37 | raise AlreadyRegisteredException('The document {doc} is already registered.'.format(doc=name)) 38 | 39 | else: 40 | if document_klass.name and document_klass.name in self._registry: 41 | raise AlreadyRegisteredException('The document {doc} is already registered.'.format( 42 | doc=document_klass.name)) 43 | else: 44 | if document_klass.__name__ in self._registry: 45 | raise AlreadyRegisteredException('The document {doc} is already registered.'.format( 46 | doc=document_klass.name)) 47 | 48 | if not issubclass(document_klass, PDFDocument): 49 | raise ImproperlyConfigured('The document {doc} is not a Document class'.format(doc=document_klass.__name__)) 50 | 51 | if name: 52 | self._registry[name] = document_klass 53 | elif document_klass.name: 54 | self._registry[document_klass.name] = document_klass 55 | else: 56 | self._registry[document_klass.__name__] = document_klass 57 | 58 | def unregister(self, document_klass, name=None): 59 | """ 60 | Removes a document from the registry, if already used elsewhere. 61 | """ 62 | if name: 63 | if name in self._registry: 64 | if document_klass is not self._registry[name]: 65 | raise NotRegisteredError('The document {doc} is not registered under the name {name}'.format( 66 | doc=document_klass.__name__, name=name)) 67 | else: 68 | raise NotRegisteredError('The name {name} is not registered'.format(name=name)) 69 | del self._registry[name] 70 | else: 71 | if document_klass.name: 72 | if document_klass.name not in self._registry: 73 | raise NotRegisteredError('The document {doc} is not registered'.format(doc=document_klass.__name__)) 74 | del self._registry[document_klass.name] 75 | else: 76 | if document_klass.__name__ not in self._registry: 77 | raise NotRegisteredError('The document {doc} is not registered'.format(doc=document_klass.__name__)) 78 | del self._registry[document_klass.__name__] 79 | 80 | def get_urls(self): 81 | from django.urls import re_path 82 | urlpatterns = [] 83 | for name, view in self._registry.items(): 84 | urlpatterns.append(re_path(r'^{name}/$'.format(name=view.url_name), view.as_view(), name=name)) 85 | return urlpatterns 86 | 87 | @property 88 | def urls(self): 89 | return self.get_urls(), 'documents', self.name 90 | 91 | docmaker = DocMaker() 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | import importlib 4 | import imp as _imp 5 | 6 | 7 | # autodiscover all documents 8 | def autodiscover_documents(): 9 | for app in settings.INSTALLED_APPS: 10 | # Django 1.7 allows for speciying a class name in INSTALLED_APPS. 11 | # (Issue #2248). 12 | try: 13 | importlib.import_module(app) 14 | except ImportError: 15 | package, _, _ = app.rpartition('.') 16 | 17 | try: 18 | pkg_path = importlib.import_module(app).__path__ 19 | except AttributeError: 20 | continue 21 | 22 | try: 23 | _imp.find_module('documents', pkg_path) 24 | except ImportError: 25 | continue 26 | 27 | importlib.import_module('{0}.{1}'.format(app, 'documents')) 28 | 29 | autodiscover_documents() 30 | -------------------------------------------------------------------------------- /templates/docmaker/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block content %} 15 | {% endblock %} 16 | 17 | --------------------------------------------------------------------------------