├── pdfutils ├── models.py ├── static │ └── pdfutils │ │ └── css │ │ ├── base.css │ │ ├── landscape.css │ │ └── portrait.css ├── __init__.py ├── utils.py ├── reports.py └── sites.py ├── .gitignore ├── setup.py ├── do-release ├── README.rst ├── changelog └── .gitchangelog.rc /pdfutils/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | *~ 5 | *.egg-info 6 | venv 7 | dist 8 | build 9 | local_settings.py 10 | -------------------------------------------------------------------------------- /pdfutils/static/pdfutils/css/base.css: -------------------------------------------------------------------------------- 1 | html { 2 | 3 | } 4 | 5 | h1 { 6 | text-align: center; 7 | font-size: 32px; 8 | } 9 | -------------------------------------------------------------------------------- /pdfutils/static/pdfutils/css/landscape.css: -------------------------------------------------------------------------------- 1 | @page { 2 | size: letter landscape; 3 | margin: 2.5cm 1cm; 4 | @frame header { 5 | -pdf-frame-content: header; 6 | top: 0.5cm; 7 | left: 1cm; 8 | right: 1cm; 9 | height: 2cm; 10 | } 11 | 12 | @frame footer { 13 | -pdf-frame-content: footer; 14 | bottom: 1.5cm; 15 | margin-left: 1cm; 16 | margin-right: 1cm; 17 | height: 1.5cm; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pdfutils/static/pdfutils/css/portrait.css: -------------------------------------------------------------------------------- 1 | @page { 2 | size: letter portrait; 3 | margin: 2.5cm 1cm; 4 | h3 { background: red; } 5 | @frame header { 6 | -pdf-frame-content: header; 7 | top: 0.5cm; 8 | left: 1cm; 9 | right: 1cm; 10 | height: 2cm; 11 | } 12 | 13 | @frame content { 14 | -pdf-frame-content: content; 15 | top: 2.5cm; 16 | left: 1cm; 17 | right: 1cm; 18 | height: 2cm; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from setuptools import setup, find_packages 6 | 7 | def read(fname): 8 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 9 | 10 | setup( 11 | name='PDFutils', 12 | version='1.1.3', 13 | description='Django PDFutils', 14 | long_description=(read('README.rst')), 15 | author='Maxime Haineault', 16 | author_email='haineault@gmail.com', 17 | license='BSD', 18 | url='https://github.com/h3/django-pdfutils', 19 | packages=find_packages(), 20 | include_package_data=True, 21 | package_data={'pdfutils': [ 22 | 'README.rst', 23 | 'changelog', 24 | ]}, 25 | install_requires = [ 26 | 'django>=1.4', 27 | 'decorator==3.4.0,<=4.0.2', 28 | 'reportlab==2.5', 29 | 'html5lib==0.90', 30 | 'httplib2==0.9', 31 | 'pyPdf==1.13', 32 | 'xhtml2pdf==0.0.4', 33 | ], 34 | zip_safe=False, 35 | classifiers=[ 36 | 'Development Status :: 3 - Alpha', 37 | 'Environment :: Web Environment', 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: BSD License', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python', 42 | 'Framework :: Django', 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /pdfutils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Taken largely from django.contrib.admin 3 | """ 4 | 5 | from pdfutils.sites import ReportSite, site 6 | 7 | 8 | def autodiscover(): 9 | """ 10 | Auto-discover INSTALLED_APPS report.py modules and fail silently when 11 | not present. This forces an import on them to register any report bits they 12 | may want. 13 | """ 14 | 15 | import copy 16 | from django.conf import settings 17 | from django.utils.importlib import import_module 18 | from django.utils.module_loading import module_has_submodule 19 | 20 | for app in settings.INSTALLED_APPS: 21 | mod = import_module(app) 22 | # Attempt to import the app's admin module. 23 | try: 24 | before_import_registry = copy.copy(site._registry) 25 | import_module('%s.report' % app) 26 | except: 27 | # Reset the model registry to the state before the last import as 28 | # this import will have to reoccur on the next request and this 29 | # could raise NotRegistered and AlreadyRegistered exceptions 30 | # (see #8245). 31 | site._registry = before_import_registry 32 | 33 | # Decide whether to bubble up this error. If the app just 34 | # doesn't have an admin module, we can ignore the error 35 | # attempting to import it, otherwise we want it to bubble up. 36 | if module_has_submodule(mod, 'report'): 37 | raise 38 | -------------------------------------------------------------------------------- /pdfutils/utils.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | import os 3 | import StringIO 4 | 5 | from django.conf import settings 6 | from django.template.context import Context 7 | from django.template.loader import get_template 8 | 9 | from xhtml2pdf import pisa # TODO: Change this when the lib changes. 10 | 11 | """ 12 | 13 | The code below is taken from django-xhtml2pdf 14 | 15 | https://raw.github.com/chrisglass/django-xhtml2pdf/master/django_xhtml2pdf/utils.py 16 | 17 | """ 18 | 19 | 20 | class UnsupportedMediaPathException(Exception): 21 | pass 22 | 23 | 24 | def unique(seq): 25 | seen = set() 26 | seen_add = seen.add 27 | return [ x for x in seq if not (x in seen or seen_add(x))] 28 | 29 | 30 | def fetch_resources(uri, rel): 31 | """ 32 | Callback to allow xhtml2pdf/reportlab to retrieve Images,Stylesheets, etc. 33 | `uri` is the href attribute from the html link element. 34 | `rel` gives a relative path, but it's not used here. 35 | """ 36 | if uri.startswith('http://') or uri.startswith('https://'): 37 | return uri 38 | if uri.startswith(settings.MEDIA_URL): 39 | path = os.path.join(settings.MEDIA_ROOT, 40 | uri.replace(settings.MEDIA_URL, "")) 41 | elif uri.startswith(settings.STATIC_URL): 42 | path = os.path.join(settings.STATIC_ROOT, 43 | uri.replace(settings.STATIC_URL, "")) 44 | if not os.path.exists(path): 45 | for d in settings.STATICFILES_DIRS: 46 | path = os.path.join(d, uri.replace(settings.STATIC_URL, "")) 47 | if os.path.exists(path): 48 | break 49 | else: 50 | raise UnsupportedMediaPathException( 51 | 'media urls must start with %s or %s' % ( 52 | settings.MEDIA_ROOT, settings.STATIC_ROOT)) 53 | return path 54 | 55 | 56 | def generate_pdf_template_object(template_object, file_object, context): 57 | """ 58 | Inner function to pass template objects directly instead of passing a filename 59 | """ 60 | html = template_object.render(Context(context)) 61 | pisaStatus = pisa.CreatePDF(html.encode("UTF-8"), file_object , encoding='UTF-8') 62 | file_object.close() 63 | return pisaStatus.err 64 | 65 | 66 | 67 | def generate_pdf(template_name, file_object=None, context=None): # pragma: no cover 68 | """ 69 | Uses the xhtml2pdf library to render a PDF to the passed file_object, from the 70 | given template name. 71 | 72 | This returns the passed-in file object, filled with the actual PDF data. 73 | In case the passed in file object is none, it will return a StringIO instance. 74 | """ 75 | if not file_object: 76 | file_object = StringIO.StringIO() 77 | if not context: 78 | context = {} 79 | tmpl = get_template(template_name) 80 | pisaStatus = generate_pdf_template_object(tmpl, file_object, context) 81 | file_object.close() 82 | return pisaStatus 83 | -------------------------------------------------------------------------------- /do-release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Returns 1 if the current git branch is dirty, else 0 4 | function git_is_dirty { 5 | [[ $(git diff --shortstat 2> /dev/null | tail -n1) != "" ]] && echo 1 || echo 0 6 | } 7 | 8 | # Returns the number of untracked files 9 | function git_has_untracked_files { 10 | expr `git status --porcelain 2>/dev/null| grep "^??" | wc -l` 11 | } 12 | 13 | function get_next_version { 14 | $MAJOR=$1 15 | $MINOR=$2 16 | $REV=$(expr $3 + 1) 17 | echo "$MAJOR.$MINOR.$REV" 18 | echo "$MAJOR.$MINOR.$REV" 19 | } 20 | 21 | CURRENT_VERSION=$(grep "version='[0-9].[0-9].[0-9]'" setup.py | grep -o '[0-9].[0-9].[0-9]') 22 | CV=( ${CURRENT_VERSION//./ } ) 23 | CURRENT_MAJOR=${CV[0]} 24 | CURRENT_MINOR=${CV[1]} 25 | CURRENT_REV=${CV[2]} 26 | 27 | NEXT_MAJOR=$CURRENT_MAJOR 28 | NEXT_MINOR=$CURRENT_MINOR 29 | NEXT_REV=`expr $CURRENT_REV + 1` 30 | if [ $NEXT_REV -gt 9 ]; then 31 | NEXT_MINOR=`expr $NEXT_MINOR + 1` 32 | NEXT_REV=0 33 | if [ $NEXT_MINOR -gt 9 ]; then 34 | NEXT_MINOR=0 35 | NEXT_MAJOR=`expr $NEXT_MAJOR + 1` 36 | fi 37 | fi 38 | NEXT_VERSION="$NEXT_MAJOR.$NEXT_MINOR.$NEXT_REV" 39 | BRANCH="release_${NEXT_MAJOR}_${NEXT_MINOR}" 40 | 41 | echo "You are about to build and publish version $NEXT_VERSION" 42 | read -sn 1 -p "Press any key to continue..." 43 | echo 44 | 45 | # Make sure we are in sync with remote 46 | git checkout -q master && git fetch -q && git pull -q 47 | 48 | # Verify that there is nothing to commit 49 | if [ $(git_is_dirty) -eq 1 ]; then 50 | git status 51 | echo 52 | echo -e " [error] Hey dumbass, commit your shit.. Abort !\n\n" 53 | #exit 1 54 | fi 55 | 56 | # Verify that there is no untracked files 57 | if [ $(git_has_untracked_files) -gt 0 ]; then 58 | git status 59 | echo 60 | echo -e " [error] I see untracked files, you dirty bastard.. Abort !\n\n" 61 | exit 1 62 | fi 63 | 64 | echo " [info] Bumping version" 65 | sed -i -e "s/ version='"$CURRENT_VERSION"',/ version='"$NEXT_VERSION"',/" setup.py 66 | git commit -aqm "Version bump: $CURRENT_VERSION -> $NEXT_VERSION @wip" 67 | git push -q 68 | 69 | echo " [info] Creating tag v$NEXT_VERSION" 70 | git tag -s "v$NEXT_VERSION" -m "Release $NEXT_VERSION" 71 | git push --tags origin "v$NEXT_VERSION" 72 | 73 | echo " [info] Updating changelog" 74 | if [ ! -d "./venv" ]; then 75 | virtualenv venv 76 | fi 77 | ./venv/bin/pip install -q gitchangelog 78 | ./venv/bin/gitchangelog > changelog 79 | rm -rf venv 80 | git add changelog 81 | git commit -qm "Updated changelog @wip" 82 | git push -q 83 | 84 | # check if a git branch exists for the current major_minor release (ex: release_1_0) 85 | if [ `git branch --list $BRANCH` ]; then # if not, create one. 86 | echo " [info] UPDATING release branch $BRANCH" 87 | git checkout -q $BRANCH 88 | git merge -q origin/master 89 | else 90 | echo " [info] CREATING release branch $BRANCH" 91 | git checkout -q -b $BRANCH 92 | fi 93 | git push -q --set-upstream origin $BRANCH > /dev/null 94 | git checkout -q master 95 | 96 | echo " [info] Pushing to PyPi" 97 | python setup.py sdist build upload 98 | 99 | echo " [done] Published release v$NEXT_VERSION" 100 | echo 101 | exit 0 102 | -------------------------------------------------------------------------------- /pdfutils/reports.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import posixpath 5 | import sys 6 | import tempfile 7 | 8 | try: 9 | from urllib.parse import unquote 10 | except ImportError: # Python 2 11 | from urllib import unquote 12 | 13 | from django.conf import settings 14 | from django.contrib.staticfiles import finders 15 | from django.http import HttpResponse 16 | from django.shortcuts import render_to_response 17 | from django.views.generic import TemplateView 18 | 19 | from pdfutils.utils import generate_pdf, unique 20 | 21 | 22 | class ReportBase(TemplateView): 23 | title = u'Untitled report' 24 | orientation = 'portrait' 25 | 26 | def __init__(self): 27 | self.__styles = ['pdfutils/css/base.css'] 28 | 29 | def filename(self): 30 | return 'Untitled-document.pdf' 31 | 32 | def get_format(self): 33 | return self.request.GET.get('format', 'pdf') 34 | 35 | def get_context_data(self): 36 | self.context = { 37 | 'title': self.title, 38 | 'slug': self.slug, 39 | 'user': self.request.user, 40 | 'orientation': self.orientation, 41 | 'MEDIA_URL': settings.MEDIA_URL, 42 | 'STATIC_URL': settings.STATIC_URL, 43 | 'STYLES': self.render_styles(), 44 | } 45 | return self.context 46 | 47 | def add_styles(self, styles): 48 | self.__styles = [] 49 | 50 | for style in styles: 51 | self.__styles.append(style) 52 | 53 | def get_styles(self): 54 | if self.orientation == 'portrait': 55 | self.__styles.append('pdfutils/css/portrait.css') 56 | else: 57 | self.__styles.append('pdfutils/css/landscape.css') 58 | self.__styles = unique(self.__styles) 59 | return self.__styles 60 | 61 | def render_styles(self): 62 | """ 63 | Eventually this should return a list of tags 64 | instead of inline styles. xhtml2pdf has a weird bug 65 | which prevents external stylesheet from working. 66 | """ 67 | out = [] 68 | for style in self.get_styles(): 69 | path = style 70 | normalized_path = posixpath.normpath(unquote(path)).lstrip('/') 71 | absolute_path = finders.find(normalized_path) 72 | if absolute_path: 73 | with open (absolute_path, "r") as fd: 74 | out.append(fd.read()) 75 | else: 76 | print "[pdfutils error] File not found: %s" % style 77 | return '' % ''.join(out) 78 | 79 | def render_to_file(self): 80 | """ 81 | Renders a PDF report to a temporary file 82 | """ 83 | return generate_pdf(self.template_name, context=self.get_context_data()) 84 | 85 | def render(self): 86 | """ 87 | Renders a PDF report to the HttpRequest object 88 | """ 89 | ctx = self.get_context_data() 90 | self.response = HttpResponse(mimetype='application/pdf', \ 91 | content_type='application/pdf; name=%s' % self.filename()) 92 | 93 | generate_pdf(self.template_name, \ 94 | file_object=self.response, context=ctx) 95 | 96 | self.response['Content-Disposition'] = 'inline; filename=%s' % \ 97 | self.filename() 98 | 99 | return self.response 100 | 101 | def get(self, request, *args, **kwargs): 102 | if self.get_format() == 'pdf': 103 | return self.render() 104 | return super(ReportBase, self).get(request, *args, **kwargs) 105 | 106 | 107 | class Report(ReportBase): 108 | pass 109 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-pdfutils 2 | =============== 3 | 4 | A simple django app to generate PDF documents. 5 | 6 | 7 | Installation 8 | ------------ 9 | 10 | 1. In your `settings.py`, add `pdfutils` to your `INSTALLED_APPS`. 11 | 2. `(r'^reports/', include(pdfutils.site.urls)),` to your `urls.py` 12 | 3. Add `pdfutils.autodiscover()` to your `urls.py` 13 | 4. Create a `report.py` file in any installed django application. 14 | 5. Create your report(s) 15 | 6. Profit! 16 | 17 | **Note**: If you are using buildout, don't forget to put `pdfutils` 18 | in your `eggs` section or else the django-pdfutils dependencies wont 19 | be installed. 20 | 21 | 22 | Example report 23 | -------------- 24 | 25 | Reports are basically views with custom methods and properties. 26 | 27 | .. code-block:: python 28 | 29 | # -*- coding: utf-8 -*- 30 | 31 | from django.contrib.auth.models import User 32 | from django.core.urlresolvers import reverse 33 | from django.utils.translation import ugettext as _ 34 | 35 | from pdfutils.reports import Report 36 | from pdfutils.sites import site 37 | 38 | 39 | class MyUserReport(Report): 40 | title = _('Users') 41 | template_name = 'myapp/reports/users-report.html' 42 | slug = 'users-report' 43 | orientation = 'portrait' 44 | 45 | def get_users(self): 46 | return User.objects.filter(is_staff=True) 47 | 48 | def get_styles(self): 49 | """ 50 | It is possible to add or override style like so 51 | """ 52 | self.add_styles('myapp/css/users-report.css') 53 | return super(AccountStatementReport, self).get_styles() 54 | 55 | def filename(self): 56 | """ 57 | The filename can be generated dynamically and translated 58 | """ 59 | return _('Users-report-%(count)s.pdf') % {'count': self.get_users().count() } 60 | 61 | def get_context_data(self): 62 | """ 63 | Context data is injected just like a normal view 64 | """ 65 | context = super(AccountStatementReport, self).get_context_data() 66 | context['user_list'] = self.get_users() 67 | return context 68 | 69 | site.register(MyUserReport) 70 | 71 | 72 | The slug should obviously be unique since it is used to build the report URL. 73 | 74 | For example, with the default settings and URLs, the URL for report above would be `/reports/users-report/`. 75 | 76 | Example template 77 | ---------------- 78 | 79 | .. code-block:: html 80 | 81 | 82 | 83 | {{ STYLES|safe }} 84 | 85 | 86 | 91 | Add ?format=html for easy template debug 92 | 93 | 94 | 95 | Some template variables are injected by default in reports: 96 | 97 | * title 98 | * slug 99 | * orientation 100 | * MEDIA_URL 101 | * STATIC_URL 102 | * STYLES 103 | 104 | 105 | Overriding default CSS 106 | ---------------------- 107 | 108 | Since the default CSS (base.css, portrait.css, landscape.css) are normal static files, they can be overrided 109 | from any other django app which has a `pdfutils` folder in their static folder. 110 | 111 | Note: Be sure your applications are listed in the right order in `INSTALLED_APPS` ! 112 | 113 | 114 | Dependencies 115 | ------------ 116 | 117 | * django >=1.4, < 1.5.99 118 | * decorator == 3.4.0, <= 3.9.9 119 | * PIL == 1.1.7 120 | * reportlab == 2.5 121 | * html5lib == 0.90 122 | * httplib2 == 0.9 123 | * pyPdf == 1.13 124 | * xhtml2pdf == 0.0.4 125 | * django-xhtml2pdf == 0.0.3 126 | 127 | **Note**: dependencies versions are specified in `setup.py`. The amount of time required to find the right 128 | combination of dependency versions is largely to blame for the creation of this project. 129 | -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v1.1.3 (2015-04-11) 5 | ------------------- 6 | 7 | - Merge pull request #7 from dominicmontreuil/patch-4. [Maxime 8 | Haineault] 9 | 10 | Letting base manage styles 11 | 12 | - Letting base manage styles. [Dominic Montreuil] 13 | 14 | Mangling self.styles to self.__styles should let classes subclassing ReportBase know that you shouldn't add to styles directly and let the base class manage them. 15 | 16 | The reason in this case is to let ReportBase handle the styles and work around the threadlock issues by cleaning the styles prior to a new report being executed. Doing so has the effect of minimizing work in the utils.unique method making sure that before each report execution we have a minimum or more explicitely the relevant styles for our current report. 17 | 18 | Classes subclassing ReportBase should use the append_styles(self, styles) method instead of appending to styles directly. 19 | 20 | - Merge pull request #9 from dominicmontreuil/patch-3. [Maxime 21 | Haineault] 22 | 23 | Better practice in utils.py 24 | 25 | - Better practice in utils.py. [Dominic Montreuil] 26 | 27 | The link_callback wasn't really used...although it didn't cause harm either. 28 | 29 | generate_pdf and generate_pdf_template_object didn't return anything useful and whatever they returned wasn't used anyways. Pisa documentation recommends that such functions/methods return the generation errors for exception catching. Not implemented here, but just an overall better practice. 30 | 31 | Also is the addition of file_object.close(). You should manually close your StringIO objects as in some reported errors not closing them was the cause for massive slowdowns and hungry ressource usage by xhtml2pdf. 32 | 33 | All the recommended changes here are optional, but the StringIO manual close is recommended very highly to Pisa/xhtml2pdf users. 34 | 35 | v1.0.9 (2015-03-28) 36 | ------------------- 37 | 38 | - Fixed setup.py. [Maxime Haineault] 39 | 40 | v1.0.8 (2015-03-28) 41 | ------------------- 42 | 43 | - Fixed important memory leak in style loading (thanks Dom) [Maxime 44 | Haineault] 45 | 46 | v1.0.7 (2015-03-28) 47 | ------------------- 48 | 49 | - Merge pull request #3 from dulaccc/format-filter. [Maxime Haineault] 50 | 51 | Add a format filter to simplify debugging 52 | 53 | - Add a filter using GET parameters to simplify debugging. [Pierre 54 | Dulac] 55 | 56 | - Add autodiscover step to the readme file. [Pierre Dulac] 57 | 58 | - Merge branch 'dulaccc-add-url-name' [Maxime Haineault] 59 | 60 | - Add a url name property to the urlpattern generated. [Maxime 61 | Haineault] 62 | 63 | - Add a url name property to the urlpattern generated. [Pierre Dulac] 64 | 65 | - Merge branch 'dulaccc-fix-resource-remote-url' [Maxime Haineault] 66 | 67 | - .. [Maxime Haineault] 68 | 69 | - Let pisa handle remote resources. [Pierre Dulac] 70 | 71 | - Merge pull request #6 from dominicmontreuil/patch-1. [Maxime 72 | Haineault] 73 | 74 | Update setup.py @wip 75 | 76 | - Update setup.py. [Dominic Montreuil] 77 | 78 | I needed to use pdfutils along with another package that required httplib2 0.9 or higher. 79 | 80 | I tested pdfutils with httplib2 0.9 and everything works. It should be safe to update to the newer version of httplib2. 81 | 82 | - Cleaning up venv after build. [Maxime Haineault] 83 | 84 | v1.0.6 (2015-03-27) 85 | ------------------- 86 | 87 | - Cleanup. [Maxime Haineault] 88 | 89 | v1.0.5 (2015-03-27) 90 | ------------------- 91 | 92 | - Fixed build script. [Maxime Haineault] 93 | 94 | v1.0.4 (2015-03-27) 95 | ------------------- 96 | 97 | - Let pisa handle remote resources. [Maxime Haineault] 98 | 99 | https://github.com/dulaccc/django-pdfutils/commit/e92304c2c952a902c6461787aec2cb269595b738 100 | 101 | - Add a url name property to the urlpattern generated. [Maxime 102 | Haineault] 103 | 104 | https://github.com/dulaccc/django-pdfutils/commit/161c7fc044d21dbaf91c57266f4b408846af8122 105 | 106 | - Updated to httplib2 0.9. [Maxime Haineault] 107 | 108 | - Messed up version, dammit. [Maxime Haineault] 109 | 110 | v1.0.3 (2015-03-27) 111 | ------------------- 112 | 113 | - Wheel doesn't build. well.. no wheel. [Maxime Haineault] 114 | 115 | v1.0.2 (2015-03-27) 116 | ------------------- 117 | 118 | - Updated .gitignore. [Maxime Haineault] 119 | 120 | - Updated build script, now building wheel. [Maxime Haineault] 121 | 122 | v1.0.1 (2015-03-27) 123 | ------------------- 124 | 125 | - Updated .gitignore, almost finished build script, updated setup.py. 126 | [Maxime Haineault] 127 | 128 | - Added a release script. [Maxime Haineault] 129 | 130 | - Merged. [Maxime Haineault] 131 | 132 | - Update setup.py. [Maxime Haineault] 133 | 134 | - Updated urls for django 1.5+ (unused), updated gitignore. [Maxime 135 | Haineault] 136 | 137 | - Fixed small bug in get_context. [Maxime Haineault] 138 | 139 | - Removed maximum django version restriction, updated gitignore. [Maxime 140 | Haineault] 141 | 142 | - Removed PIL dependency. [Maxime Haineault] 143 | 144 | - Removed django-xhtml2pdf dependency. [Maxime Haineault] 145 | 146 | - Removed THUMBNAIL_MEDIA_URL from template variables. [Maxime 147 | Haineault] 148 | 149 | 150 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | ## 2 | ## Format 3 | ## 4 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 5 | ## 6 | ## Description 7 | ## 8 | ## ACTION is one of 'chg', 'fix', 'new' 9 | ## 10 | ## Is WHAT the change is about. 11 | ## 12 | ## 'chg' is for refactor, small improvement, cosmetic changes... 13 | ## 'fix' is for bug fixes 14 | ## 'new' is for new features, big improvement 15 | ## 16 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 17 | ## 18 | ## Is WHO is concerned by the change. 19 | ## 20 | ## 'dev' is for developpers (API changes, refactors...) 21 | ## 'usr' is for final users (UI changes) 22 | ## 'pkg' is for packagers (packaging changes) 23 | ## 'test' is for testers (test only related changes) 24 | ## 'doc' is for doc guys (doc only changes) 25 | ## 26 | ## COMMIT_MSG is ... well ... the commit message itself. 27 | ## 28 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 29 | ## 30 | ## They are preceded with a '!' or a '@' (prefer the former, as the 31 | ## latter is wrongly interpreted in github.) Commonly used tags are: 32 | ## 33 | ## 'refactor' is obviously for refactoring code only 34 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 35 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 36 | ## 'wip' is for partial functionality but complete subfunctionality. 37 | ## 38 | ## Example: 39 | ## 40 | ## new: usr: support of bazaar implemented 41 | ## chg: re-indentend some lines !cosmetic 42 | ## new: dev: updated code to be compatible with last version of killer lib. 43 | ## fix: pkg: updated year of licence coverage. 44 | ## new: test: added a bunch of test around user usability of feature X. 45 | ## fix: typo in spelling my name in comment. !minor 46 | ## 47 | ## Please note that multi-line commit message are supported, and only the 48 | ## first line will be considered as the "summary" of the commit message. So 49 | ## tags, and other rules only applies to the summary. The body of the commit 50 | ## message will be displayed in the changelog without reformatting. 51 | 52 | 53 | ## 54 | ## ``ignore_regexps`` is a line of regexps 55 | ## 56 | ## Any commit having its full commit message matching any regexp listed here 57 | ## will be ignored and won't be reported in the changelog. 58 | ## 59 | ignore_regexps = [ 60 | r'@minor', r'!minor', 61 | r'@cosmetic', r'!cosmetic', 62 | r'@refactor', r'!refactor', 63 | r'@wip', r'!wip', '.*@(wip|refactor|minor|cosmetic).*', 64 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 65 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 66 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 67 | r'^Version bump.*', 68 | r'^.*README.*', 69 | r'^Initial .*', 70 | r'.*swap file$', 71 | r'.*swap files$', 72 | ] 73 | 74 | 75 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 76 | ## list of regexp 77 | ## 78 | ## Commit messages will be classified in sections thanks to this. Section 79 | ## titles are the label, and a commit is classified under this section if any 80 | ## of the regexps associated is matching. 81 | ## 82 | section_regexps = [ 83 | ('New', [ 84 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 85 | ]), 86 | ('Changes', [ 87 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 88 | ]), 89 | ('Fix', [ 90 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 91 | ]), 92 | 93 | ('Other', None ## Match all lines 94 | ), 95 | 96 | ] 97 | 98 | 99 | ## ``body_process`` is a callable 100 | ## 101 | ## This callable will be given the original body and result will 102 | ## be used in the changelog. 103 | ## 104 | ## Available constructs are: 105 | ## 106 | ## - any python callable that take one txt argument and return txt argument. 107 | ## 108 | ## - ReSub(pattern, replacement): will apply regexp substitution. 109 | ## 110 | ## - Indent(chars=" "): will indent the text with the prefix 111 | ## Please remember that template engines gets also to modify the text and 112 | ## will usually indent themselves the text if needed. 113 | ## 114 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 115 | ## 116 | ## - noop: do nothing 117 | ## 118 | ## - ucfirst: ensure the first letter is uppercase. 119 | ## (usually used in the ``subject_process`` pipeline) 120 | ## 121 | ## - final_dot: ensure text finishes with a dot 122 | ## (usually used in the ``subject_process`` pipeline) 123 | ## 124 | ## - strip: remove any spaces before or after the content of the string 125 | ## 126 | ## Additionally, you can `pipe` the provided filters, for instance: 127 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 128 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 129 | #body_process = noop 130 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 131 | 132 | 133 | ## ``subject_process`` is a callable 134 | ## 135 | ## This callable will be given the original subject and result will 136 | ## be used in the changelog. 137 | ## 138 | ## Available constructs are those listed in ``body_process`` doc. 139 | subject_process = (strip | 140 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 141 | ucfirst | final_dot) 142 | 143 | 144 | ## ``tag_filter_regexp`` is a regexp 145 | ## 146 | ## Tags that will be used for the changelog must match this regexp. 147 | ## 148 | tag_filter_regexp = r'^v[0-9]+\.[0-9]+(\.[0-9]+)?$' 149 | 150 | 151 | ## ``unreleased_version_label`` is a string 152 | ## 153 | ## This label will be used as the changelog Title of the last set of changes 154 | ## between last valid tag and HEAD if any. 155 | unreleased_version_label = "%%version%% (unreleased)" 156 | 157 | 158 | ## ``output_engine`` is a callable 159 | ## 160 | ## This will change the output format of the generated changelog file 161 | ## 162 | ## Available choices are: 163 | ## 164 | ## - rest_py 165 | ## 166 | ## Legacy pure python engine, outputs ReSTructured text. 167 | ## This is the default. 168 | ## 169 | ## - mustache() 170 | ## 171 | ## Template name could be any of the available templates in 172 | ## ``templates/mustache/*.tpl``. 173 | ## Requires python package ``pystache``. 174 | ## Examples: 175 | ## - mustache("markdown") 176 | ## - mustache("restructuredtext") 177 | ## 178 | ## - makotemplate() 179 | ## 180 | ## Template name could be any of the available templates in 181 | ## ``templates/mako/*.tpl``. 182 | ## Requires python package ``mako``. 183 | ## Examples: 184 | ## - makotemplate("restructuredtext") 185 | ## 186 | output_engine = rest_py 187 | #output_engine = mustache("restructuredtext") 188 | #output_engine = mustache("markdown") 189 | #output_engine = makotemplate("restructuredtext") 190 | 191 | 192 | ## ``include_merges`` is a boolean 193 | ## 194 | ## This option tells git-log whether to include merge commits in the log. 195 | ## The default is to include them. 196 | include_merges = True 197 | -------------------------------------------------------------------------------- /pdfutils/sites.py: -------------------------------------------------------------------------------- 1 | """ 2 | Taken largely from django.contrib.admin 3 | """ 4 | from functools import update_wrapper 5 | from django.http import Http404, HttpResponseRedirect 6 | from django.contrib.admin import ModelAdmin, actions 7 | from django.contrib.admin.forms import AdminAuthenticationForm 8 | from django.contrib.auth import logout as auth_logout, REDIRECT_FIELD_NAME 9 | from django.contrib.contenttypes import views as contenttype_views 10 | from django.views.decorators.csrf import csrf_protect 11 | from django.core.exceptions import ImproperlyConfigured 12 | from django.core.urlresolvers import reverse, NoReverseMatch 13 | from django.template.response import TemplateResponse 14 | from django.utils import six 15 | from django.utils.text import capfirst 16 | from django.utils.translation import ugettext as _ 17 | from django.views.decorators.cache import never_cache 18 | from django.conf import settings 19 | 20 | from pdfutils.reports import ReportBase, Report 21 | 22 | LOGIN_FORM_KEY = 'this_is_the_login_form' 23 | 24 | 25 | class AlreadyRegistered(Exception): 26 | pass 27 | 28 | 29 | class NotRegistered(Exception): 30 | pass 31 | 32 | 33 | class ReportSite(object): 34 | """ 35 | An AdminSite object encapsulates an instance of the Django admin application, ready 36 | to be hooked in to your URLconf. Models are registered with the AdminSite using the 37 | register() method, and the get_urls() method can then be used to access Django view 38 | functions that present a full admin interface for the collection of registered 39 | models. 40 | """ 41 | index_template = None 42 | 43 | def __init__(self, name='pdfutils', app_name='report'): 44 | self._registry = {} # model_class class -> admin_class instance 45 | self.name = name 46 | self.app_name = app_name 47 | 48 | def register(self, report_or_iterable, **options): 49 | """ 50 | Registers the given report(s) with the given admin class. 51 | 52 | The report(s) should be report classes, not instances. 53 | 54 | If a report is already registered, this will raise AlreadyRegistered. 55 | 56 | If a report is abstract, this will raise ImproperlyConfigured. 57 | """ 58 | if issubclass(report_or_iterable, ReportBase): 59 | report_or_iterable = [report_or_iterable] 60 | for report in report_or_iterable: 61 | #if report._meta.abstract: 62 | # raise ImproperlyConfigured('The report %s is abstract, so it ' 63 | # 'cannot be registered with pdfutils.' % report.__name__) 64 | 65 | if report in self._registry: 66 | raise AlreadyRegistered('The report %s is already registered' % report.__name__) 67 | 68 | #if admin_class is not Report and settings.DEBUG: 69 | # admin_class.validate(report) 70 | 71 | # Instantiate the admin class to save in the registry 72 | self._registry[report] = report(**options) 73 | 74 | def unregister(self, report_or_iterable): 75 | """ 76 | Unregisters the given report(s). 77 | 78 | If a report isn't already registered, this will raise NotRegistered. 79 | """ 80 | if isinstance(report_or_iterable, Report): 81 | report_or_iterable = [report_or_iterable] 82 | for report in report_or_iterable: 83 | if report not in self._registry: 84 | raise NotRegistered('The report %s is not registered' % report.__name__) 85 | del self._registry[report] 86 | 87 | def has_permission(self, request): 88 | """ 89 | Returns True if the given HttpRequest has permission to view 90 | *at least one* page in the admin site. 91 | """ 92 | return request.user.is_active and request.user.is_staff 93 | 94 | def check_dependencies(self): 95 | """ 96 | Check that all things needed to run the admin have been correctly installed. 97 | 98 | The default implementation checks that LogEntry, ContentType and the 99 | auth context processor are installed. 100 | """ 101 | from django.contrib.admin.models import LogEntry 102 | from django.contrib.contenttypes.models import ContentType 103 | 104 | if not LogEntry._meta.installed: 105 | raise ImproperlyConfigured("Put 'django.contrib.admin' in your " 106 | "INSTALLED_APPS setting in order to use the admin application.") 107 | if not ContentType._meta.installed: 108 | raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in " 109 | "your INSTALLED_APPS setting in order to use the admin application.") 110 | if not ('django.contrib.auth.context_processors.auth' in settings.TEMPLATE_CONTEXT_PROCESSORS or 111 | 'django.core.context_processors.auth' in settings.TEMPLATE_CONTEXT_PROCESSORS): 112 | raise ImproperlyConfigured("Put 'django.contrib.auth.context_processors.auth' " 113 | "in your TEMPLATE_CONTEXT_PROCESSORS setting in order to use the admin application.") 114 | 115 | def report_view(self, view, cacheable=False): 116 | """ 117 | Decorator to create an admin view attached to this ``AdminSite``. This 118 | wraps the view and provides permission checking by calling 119 | ``self.has_permission``. 120 | 121 | You'll want to use this from within ``AdminSite.get_urls()``: 122 | 123 | class MyAdminSite(AdminSite): 124 | 125 | def get_urls(self): 126 | from django.conf.urls import patterns, url 127 | 128 | urls = super(MyAdminSite, self).get_urls() 129 | urls += patterns('', 130 | url(r'^my_view/$', self.report_view(some_view)) 131 | ) 132 | return urls 133 | 134 | By default, report_views are marked non-cacheable using the 135 | ``never_cache`` decorator. If the view can be safely cached, set 136 | cacheable=True. 137 | """ 138 | def inner(request, *args, **kwargs): 139 | if LOGIN_FORM_KEY in request.POST and request.user.is_authenticated(): 140 | auth_logout(request) 141 | if not self.has_permission(request): 142 | if request.path == reverse('admin:logout', 143 | current_app=self.name): 144 | index_path = reverse('admin:index', current_app=self.name) 145 | return HttpResponseRedirect(index_path) 146 | return self.login(request) 147 | return view(request, *args, **kwargs) 148 | if not cacheable: 149 | inner = never_cache(inner) 150 | # We add csrf_protect here so this function can be used as a utility 151 | # function for any view, without having to repeat 'csrf_protect'. 152 | if not getattr(view, 'csrf_exempt', False): 153 | inner = csrf_protect(inner) 154 | return update_wrapper(inner, view) 155 | 156 | @never_cache 157 | def report(self, request, extra_context=None): 158 | """ 159 | Render report for the given HttpRequest. 160 | 161 | This should *not* assume the user is already logged in. 162 | """ 163 | from django.contrib.auth.views import logout 164 | defaults = { 165 | 'current_app': self.name, 166 | 'extra_context': extra_context or {}, 167 | } 168 | if self.report_template is not None: 169 | defaults['template_name'] = self.logout_template 170 | return logout(request, **defaults) 171 | 172 | def get_urls(self): 173 | from django.conf.urls import patterns, url, include 174 | 175 | if settings.DEBUG: 176 | self.check_dependencies() 177 | 178 | def wrap(view, cacheable=False): 179 | def wrapper(*args, **kwargs): 180 | return self.report_view(view, cacheable)(*args, **kwargs) 181 | return update_wrapper(wrapper, view) 182 | 183 | urlpatterns = patterns('') 184 | 185 | # Add in each report's views. 186 | for model_class, model_instance in six.iteritems(self._registry): 187 | urlpatterns += patterns('', 188 | url(r'^%s/' % model_instance.slug, model_class.as_view(), 189 | name=model_instance.slug)) 190 | return urlpatterns 191 | 192 | @property 193 | def urls(self): 194 | return self.get_urls(), self.app_name, self.name 195 | 196 | 197 | # This global object represents the default report site, for the common case. 198 | # You can instantiate ReportSite in your own code to create a custom report site. 199 | site = ReportSite() 200 | --------------------------------------------------------------------------------