diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index 4691c1b0c..9c9246024 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -10,5 +10,6 @@ Contents: exporter ticketoutput payment + invoice customview general diff --git a/doc/development/api/invoice.rst b/doc/development/api/invoice.rst new file mode 100644 index 000000000..d2136d044 --- /dev/null +++ b/doc/development/api/invoice.rst @@ -0,0 +1,95 @@ +.. highlight:: python + :linenothreshold: 5 + +Writing an invoice renderer plugin +================================== + +An invoice renderer controls how invoice files are built. +The creation of such a plugin is very similar to creating an export output. + +Please read :ref:`Creating a plugin ` first, if you haven't already. + +Output registration +------------------- + +The invoice renderer API does not make a lot of usage from signals, however, it +does use a signal to get a list of all available ticket outputs. Your plugin +should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer`` +that we'll provide in this plugin:: + + from django.dispatch import receiver + + from pretix.base.signals import register_invoice_renderers + + + @receiver(register_invoice_renderers, dispatch_uid="output_custom") + def register_infoice_renderers(sender, **kwargs): + from .invoice import MyInvoiceRenderer + return MyInvoiceRenderer + + +The renderer class +------------------ + +.. class:: pretix.base.invoice.BaseInvoiceRenderer + + The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``. + + .. py:attribute:: BaseInvoiceRenderer.event + + The default constructor sets this property to the event we are currently + working for. + + .. autoattribute:: identifier + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: verbose_name + + This is an abstract attribute, you **must** override this! + + .. automethod:: generate + +Helper class for reportlab-base renderers +----------------------------------------- + +All PDF rendering that ships with pretix is based on reportlab. We recommend to read the +`reportlab User Guide`_ to understand all the concepts used here. + +If you want to implement a renderer that also uses report lab, this helper class might be +convenient to you: + + +.. class:: pretix.base.invoice.BaseReportlabInvoiceRenderer + + .. py:attribute:: BaseReportlabInvoiceRenderer.pagesize + + .. py:attribute:: BaseReportlabInvoiceRenderer.left_margin + + .. py:attribute:: BaseReportlabInvoiceRenderer.right_margin + + .. py:attribute:: BaseReportlabInvoiceRenderer.top_margin + + .. py:attribute:: BaseReportlabInvoiceRenderer.bottom_margin + + .. py:attribute:: BaseReportlabInvoiceRenderer.doc_template_class + + .. py:attribute:: BaseReportlabInvoiceRenderer.invoice + + .. automethod:: _init + + .. automethod:: _get_stylesheet + + .. automethod:: _register_fonts + + .. automethod:: _on_first_page + + .. automethod:: _on_other_page + + .. automethod:: _get_first_page_frames + + .. automethod:: _get_other_page_frames + + .. automethod:: _build_doc + +.. _reportlab User Guide: https://www.reportlab.com/docs/reportlab-userguide.pdf diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index db8b41e7e..c89a3fad7 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -9,6 +9,7 @@ class PretixBaseConfig(AppConfig): from . import exporter # NOQA from . import payment # NOQA from . import exporters # NOQA + from . import invoice # NOQA from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA try: diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py new file mode 100644 index 000000000..004287c90 --- /dev/null +++ b/src/pretix/base/invoice.py @@ -0,0 +1,411 @@ +from collections import defaultdict +from decimal import Decimal +from io import BytesIO +from typing import Tuple + +from django.contrib.staticfiles import finders +from django.dispatch import receiver +from django.utils.formats import date_format, localize +from django.utils.translation import pgettext +from reportlab.lib import pagesizes +from reportlab.lib.styles import ParagraphStyle, StyleSheet1 +from reportlab.lib.units import mm +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import ( + BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer, + Table, TableStyle, +) + +from pretix.base.models import Event, Invoice +from pretix.base.signals import register_invoice_renderers + + +class BaseInvoiceRenderer: + """ + This is the base class for all invoice renderers. + """ + + def __init__(self, event: Event): + self.event = event + + def __str__(self): + return self.identifier + + def generate(self, invoice: Invoice) -> Tuple[str, str, str]: + """ + This method should generate the invoice file and return a tuple consisting of a + filename, a file type and file content. The extension will be taken from the filename + which is otherwise ignored. + """ + raise NotImplementedError() + + @property + def verbose_name(self) -> str: + """ + A human-readable name for this renderer. This should be short but + self-explanatory. Good examples include 'German DIN 5008' or 'Italian invoice'. + """ + raise NotImplementedError() # NOQA + + @property + def identifier(self) -> str: + """ + A short and unique identifier for this renderer. + This should only contain lowercase letters and in most + cases will be the same as your package name. + """ + raise NotImplementedError() # NOQA + + +class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): + """ + This is a convenience class to avoid duplicate code when implementing invoice renderers + that are based on reportlab. + """ + pagesize = pagesizes.A4 + left_margin = 25 * mm + right_margin = 20 * mm + top_margin = 20 * mm + bottom_margin = 15 * mm + doc_template_class = BaseDocTemplate + + def _init(self): + """ + Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``. + """ + self.stylesheet = self._get_stylesheet() + self._register_fonts() + + def _get_stylesheet(self): + """ + Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles. + """ + stylesheet = StyleSheet1() + stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12)) + stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2)) + return stylesheet + + def _register_fonts(self): + """ + Register fonts with reportlab. By default, this registers the OpenSans font family + """ + pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))) + pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', + italic='OpenSansIt', boldItalic='OpenSansBI') + + def _on_other_page(self, canvas: Canvas, doc): + """ + Called when a new page is rendered that is *not* the first page. + """ + pass + + def _on_first_page(self, canvas: Canvas, doc): + """ + Called when a new page is rendered that is the first page. + """ + pass + + def _get_story(self, doc): + """ + Called to create the story to be inserted into the main frames. + """ + raise NotImplementedError() + + def _get_first_page_frames(self, doc): + """ + Called to create a list of frames for the first page. + """ + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='normal') + ] + + def _get_other_page_frames(self, doc): + """ + Called to create a list of frames for the other pages. + """ + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='normal') + ] + + def _build_doc(self, fhandle): + """ + Build a PDF document in a given file handle + """ + self._init() + doc = self.doc_template_class(fhandle, pagesize=self.pagesize, + leftMargin=self.left_margin, rightMargin=self.right_margin, + topMargin=self.top_margin, bottomMargin=self.bottom_margin) + + doc.addPageTemplates([ + PageTemplate( + id='FirstPage', + frames=self._get_first_page_frames(doc), + onPage=self._on_first_page, + pagesize=self.pagesize + ), + PageTemplate( + id='OtherPages', + frames=self._get_other_page_frames(doc), + onPage=self._on_other_page, + pagesize=self.pagesize + ) + ]) + story = self._get_story(doc) + doc.build(story) + return doc + + def generate(self, invoice: Invoice): + self.invoice = invoice + buffer = BytesIO() + self._build_doc(buffer) + buffer.seek(0) + return 'invoice.pdf', 'application/pdf', buffer.read() + + +class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): + identifier = 'classic' + verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') + + def _on_other_page(self, canvas: Canvas, doc): + canvas.saveState() + canvas.setFont('OpenSans', 8) + canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,)) + + for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): + canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) + + canvas.restoreState() + + def _on_first_page(self, canvas: Canvas, doc): + canvas.setCreator('pretix.eu') + canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number)) + + canvas.saveState() + canvas.setFont('OpenSans', 8) + canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,)) + + for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): + canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) + + textobject = canvas.beginText(25 * mm, (297 - 15) * mm) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Invoice from').upper()) + canvas.drawText(textobject) + + p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '
\n'), style=self.stylesheet['Normal']) + p.wrapOn(canvas, 70 * mm, 50 * mm) + p_size = p.wrap(70 * mm, 50 * mm) + p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1]) + + textobject = canvas.beginText(25 * mm, (297 - 50) * mm) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Invoice to').upper()) + canvas.drawText(textobject) + + p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '
\n'), style=self.stylesheet['Normal']) + p.wrapOn(canvas, 85 * mm, 50 * mm) + p_size = p.wrap(85 * mm, 50 * mm) + p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1]) + + textobject = canvas.beginText(125 * mm, (297 - 38) * mm) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Order code').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(self.invoice.order.full_code) + canvas.drawText(textobject) + + textobject = canvas.beginText(125 * mm, (297 - 50) * mm) + textobject.setFont('OpenSansBd', 8) + if self.invoice.is_cancellation: + textobject.textLine(pgettext('invoice', 'Cancellation number').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(self.invoice.number) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Original invoice').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(self.invoice.refers.number) + else: + textobject.textLine(pgettext('invoice', 'Invoice number').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(self.invoice.number) + textobject.moveCursor(0, 5) + + if self.invoice.is_cancellation: + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Cancellation date').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT")) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Original invoice date').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT")) + textobject.moveCursor(0, 5) + else: + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Invoice date').upper()) + textobject.moveCursor(0, 5) + textobject.setFont('OpenSans', 10) + textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT")) + textobject.moveCursor(0, 5) + + canvas.drawText(textobject) + + if self.invoice.event.settings.invoice_logo_image: + logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True) + canvas.drawImage(ImageReader(logo_file), + 95 * mm, (297 - 38) * mm, + width=25 * mm, height=25 * mm, + preserveAspectRatio=True, anchor='n', + mask='auto') + + if self.invoice.event.settings.show_date_to: + p_str = ( + str(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format( + from_date=self.invoice.event.get_date_from_display(), + to_date=self.invoice.event.get_date_to_display()) + ) + else: + p_str = ( + str(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display() + ) + + p = Paragraph(p_str.strip().replace('\n', '
\n'), style=self.stylesheet['Normal']) + p.wrapOn(canvas, 65 * mm, 50 * mm) + p_size = p.wrap(65 * mm, 50 * mm) + p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1]) + + textobject = canvas.beginText(125 * mm, (297 - 15) * mm) + textobject.setFont('OpenSansBd', 8) + textobject.textLine(pgettext('invoice', 'Event').upper()) + canvas.drawText(textobject) + + canvas.restoreState() + + def _get_first_page_frames(self, doc): + footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, + id='normal') + ] + + def _get_other_page_frames(self, doc): + footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, + id='normal') + ] + + def _get_story(self, doc): + story = [ + NextPageTemplate('FirstPage'), + Paragraph(pgettext('invoice', 'Invoice') + if not self.invoice.is_cancellation + else pgettext('invoice', 'Cancellation'), + self.stylesheet['Heading1']), + Spacer(1, 5 * mm), + NextPageTemplate('OtherPages'), + ] + + if self.invoice.introductory_text: + story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal'])) + story.append(Spacer(1, 10 * mm)) + + taxvalue_map = defaultdict(Decimal) + grossvalue_map = defaultdict(Decimal) + + tstyledata = [ + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'), + ('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'), + ('LEFTPADDING', (0, 0), (0, -1), 0), + ('RIGHTPADDING', (-1, 0), (-1, -1), 0), + ] + tdata = [( + pgettext('invoice', 'Description'), + pgettext('invoice', 'Tax rate'), + pgettext('invoice', 'Net'), + pgettext('invoice', 'Gross'), + )] + total = Decimal('0.00') + for line in self.invoice.lines.all(): + tdata.append(( + Paragraph(line.description, self.stylesheet['Normal']), + localize(line.tax_rate) + " %", + localize(line.net_value) + " " + self.invoice.event.currency, + localize(line.gross_value) + " " + self.invoice.event.currency, + )) + taxvalue_map[line.tax_rate] += line.tax_value + grossvalue_map[line.tax_rate] += line.gross_value + total += line.gross_value + + tdata.append( + [pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + self.invoice.event.currency]) + colwidths = [a * doc.width for a in (.55, .15, .15, .15)] + table = Table(tdata, colWidths=colwidths, repeatRows=1) + table.setStyle(TableStyle(tstyledata)) + story.append(table) + + story.append(Spacer(1, 15 * mm)) + + if self.invoice.payment_provider_text: + story.append(Paragraph(self.invoice.payment_provider_text, self.stylesheet['Normal'])) + + if self.invoice.additional_text: + story.append(Paragraph(self.invoice.additional_text, self.stylesheet['Normal'])) + story.append(Spacer(1, 15 * mm)) + + tstyledata = [ + ('SPAN', (1, 0), (-1, 0)), + ('ALIGN', (2, 1), (-1, -1), 'RIGHT'), + ('LEFTPADDING', (0, 0), (0, -1), 0), + ('RIGHTPADDING', (-1, 0), (-1, -1), 0), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ] + tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''), + ('', pgettext('invoice', 'Tax rate'), + pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))] + + for rate, gross in grossvalue_map.items(): + if line.tax_rate == 0: + continue + tax = taxvalue_map[rate] + tdata.append(( + '', + localize(rate) + " %", + localize((gross - tax)) + " " + self.invoice.event.currency, + localize(gross) + " " + self.invoice.event.currency, + localize(tax) + " " + self.invoice.event.currency, + )) + + if len(tdata) > 2: + colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)] + table = Table(tdata, colWidths=colwidths, repeatRows=2) + table.setStyle(TableStyle(tstyledata)) + story.append(table) + return story + + +@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") +def recv_classic(sender, **kwargs): + return ClassicInvoiceRenderer diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index bd004de83..aa3fc979b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -326,6 +326,24 @@ class Event(LoggedModel): providers[pp.identifier] = pp return providers + def get_invoice_renderers(self) -> dict: + from ..signals import register_invoice_renderers + + responses = register_invoice_renderers.send(self) + renderers = {} + for receiver, response in responses: + if not isinstance(response, list): + response = [response] + for p in response: + pp = p(self) + renderers[pp.identifier] = pp + return renderers + + @property + def invoice_renderer(self): + irs = self.get_invoice_renderers() + return irs[self.settings.invoice_renderer] + def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 51670c608..60d184436 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -9,9 +9,10 @@ from django.utils.functional import cached_property def invoice_filename(instance, filename: str) -> str: secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits) - return 'invoices/{org}/{ev}/{no}-{code}-{secret}.pdf'.format( + return 'invoices/{org}/{ev}/{no}-{code}-{secret}.{ext}'.format( org=instance.event.organizer.slug, ev=instance.event.slug, - no=instance.number, code=instance.order.code, secret=secret + no=instance.number, code=instance.order.code, secret=secret, + ext=filename.split('.')[-1] ) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 4fca7791c..5877dc2d7 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -1,25 +1,11 @@ import copy -import tempfile -from collections import defaultdict from decimal import Decimal -from django.contrib.staticfiles import finders from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone -from django.utils.formats import date_format, localize from django.utils.translation import pgettext, ugettext as _ from i18nfield.strings import LazyI18nString -from reportlab.lib import pagesizes -from reportlab.lib.styles import ParagraphStyle, StyleSheet1 -from reportlab.lib.units import mm -from reportlab.lib.utils import ImageReader -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.platypus import ( - BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer, - Table, TableStyle, -) from pretix.base.i18n import language from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order @@ -77,7 +63,8 @@ def build_invoice(invoice: Invoice) -> Invoice: if invoice.order.payment_fee: InvoiceLine.objects.create( - invoice=invoice, description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)), + invoice=invoice, + description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)), gross_value=invoice.order.payment_fee, tax_value=invoice.order.payment_fee_tax_value, tax_rate=invoice.order.payment_fee_tax_rate ) @@ -138,265 +125,12 @@ def generate_invoice(order: Order): return invoice -def _invoice_get_stylesheet(): - stylesheet = StyleSheet1() - stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12)) - stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2)) - return stylesheet - - -def _invoice_register_fonts(): - pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) - - -def _invoice_generate_german(invoice, f): - _invoice_register_fonts() - styles = _invoice_get_stylesheet() - pagesize = pagesizes.A4 - - def on_page(canvas, doc): - canvas.saveState() - canvas.setFont('OpenSans', 8) - canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,)) - - for i, line in enumerate(invoice.footer_text.split('\n')[::-1]): - canvas.drawCentredString(pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) - - canvas.restoreState() - - def on_first_page(canvas, doc): - canvas.setCreator('pretix.eu') - canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=invoice.number)) - - canvas.saveState() - canvas.setFont('OpenSans', 8) - canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,)) - - for i, line in enumerate(invoice.footer_text.split('\n')[::-1]): - canvas.drawCentredString(pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) - - textobject = canvas.beginText(25 * mm, (297 - 15) * mm) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Invoice from').upper()) - canvas.drawText(textobject) - - p = Paragraph(invoice.invoice_from.strip().replace('\n', '
\n'), style=styles['Normal']) - p.wrapOn(canvas, 70 * mm, 50 * mm) - p_size = p.wrap(70 * mm, 50 * mm) - p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1]) - - textobject = canvas.beginText(25 * mm, (297 - 50) * mm) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Invoice to').upper()) - canvas.drawText(textobject) - - p = Paragraph(invoice.invoice_to.strip().replace('\n', '
\n'), style=styles['Normal']) - p.wrapOn(canvas, 85 * mm, 50 * mm) - p_size = p.wrap(85 * mm, 50 * mm) - p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1]) - - textobject = canvas.beginText(125 * mm, (297 - 38) * mm) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(_('Order code').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(invoice.order.full_code) - canvas.drawText(textobject) - - textobject = canvas.beginText(125 * mm, (297 - 50) * mm) - textobject.setFont('OpenSansBd', 8) - if invoice.is_cancellation: - textobject.textLine(pgettext('invoice', 'Cancellation number').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(invoice.number) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Original invoice').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(invoice.refers.number) - else: - textobject.textLine(pgettext('invoice', 'Invoice number').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(invoice.number) - textobject.moveCursor(0, 5) - - if invoice.is_cancellation: - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Cancellation date').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(date_format(invoice.date, "DATE_FORMAT")) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Original invoice date').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(date_format(invoice.refers.date, "DATE_FORMAT")) - textobject.moveCursor(0, 5) - else: - textobject.setFont('OpenSansBd', 8) - textobject.textLine(pgettext('invoice', 'Invoice date').upper()) - textobject.moveCursor(0, 5) - textobject.setFont('OpenSans', 10) - textobject.textLine(date_format(invoice.date, "DATE_FORMAT")) - textobject.moveCursor(0, 5) - - canvas.drawText(textobject) - - if invoice.event.settings.invoice_logo_image: - logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True) - canvas.drawImage(ImageReader(logo_file), - 95 * mm, (297 - 38) * mm, - width=25 * mm, height=25 * mm, - preserveAspectRatio=True, anchor='n', - mask='auto') - - if invoice.event.settings.show_date_to: - p_str = ( - str(invoice.event.name) + '\n' + _('{from_date}\nuntil {to_date}').format( - from_date=invoice.event.get_date_from_display(), - to_date=invoice.event.get_date_to_display()) - ) - else: - p_str = ( - str(invoice.event.name) + '\n' + invoice.event.get_date_from_display() - ) - - p = Paragraph(p_str.strip().replace('\n', '
\n'), style=styles['Normal']) - p.wrapOn(canvas, 65 * mm, 50 * mm) - p_size = p.wrap(65 * mm, 50 * mm) - p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1]) - - textobject = canvas.beginText(125 * mm, (297 - 15) * mm) - textobject.setFont('OpenSansBd', 8) - textobject.textLine(_('Event').upper()) - canvas.drawText(textobject) - - canvas.restoreState() - - doc = BaseDocTemplate(f.name, pagesize=pagesizes.A4, - leftMargin=25 * mm, rightMargin=20 * mm, - topMargin=20 * mm, bottomMargin=15 * mm) - - footer_length = 3.5 * len(invoice.footer_text.split('\n')) * mm - frames_p1 = [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, - id='normal') - ] - frames = [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, - id='normal') - ] - doc.addPageTemplates([ - PageTemplate(id='FirstPage', frames=frames_p1, onPage=on_first_page, pagesize=pagesize), - PageTemplate(id='OtherPages', frames=frames, onPage=on_page, pagesize=pagesize) - ]) - story = [ - NextPageTemplate('FirstPage'), - Paragraph(pgettext('invoice', 'Invoice') - if not invoice.is_cancellation - else pgettext('invoice', 'Cancellation'), - styles['Heading1']), - Spacer(1, 5 * mm), - NextPageTemplate('OtherPages'), - ] - - if invoice.introductory_text: - story.append(Paragraph(invoice.introductory_text, styles['Normal'])) - story.append(Spacer(1, 10 * mm)) - - taxvalue_map = defaultdict(Decimal) - grossvalue_map = defaultdict(Decimal) - - tstyledata = [ - ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'), - ('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'), - ('LEFTPADDING', (0, 0), (0, -1), 0), - ('RIGHTPADDING', (-1, 0), (-1, -1), 0), - ] - tdata = [( - pgettext('invoice', 'Description'), - pgettext('invoice', 'Tax rate'), - pgettext('invoice', 'Net'), - pgettext('invoice', 'Gross'), - )] - total = Decimal('0.00') - for line in invoice.lines.all(): - tdata.append(( - Paragraph(line.description, styles['Normal']), - localize(line.tax_rate) + " %", - localize(line.net_value) + " " + invoice.event.currency, - localize(line.gross_value) + " " + invoice.event.currency, - )) - taxvalue_map[line.tax_rate] += line.tax_value - grossvalue_map[line.tax_rate] += line.gross_value - total += line.gross_value - - tdata.append([pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + invoice.event.currency]) - colwidths = [a * doc.width for a in (.55, .15, .15, .15)] - table = Table(tdata, colWidths=colwidths, repeatRows=1) - table.setStyle(TableStyle(tstyledata)) - story.append(table) - - story.append(Spacer(1, 15 * mm)) - - if invoice.payment_provider_text: - story.append(Paragraph(invoice.payment_provider_text, styles['Normal'])) - - if invoice.additional_text: - story.append(Paragraph(invoice.additional_text, styles['Normal'])) - story.append(Spacer(1, 15 * mm)) - - tstyledata = [ - ('SPAN', (1, 0), (-1, 0)), - ('ALIGN', (2, 1), (-1, -1), 'RIGHT'), - ('LEFTPADDING', (0, 0), (0, -1), 0), - ('RIGHTPADDING', (-1, 0), (-1, -1), 0), - ('FONTSIZE', (0, 0), (-1, -1), 8), - ] - tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''), - ('', pgettext('invoice', 'Tax rate'), - pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))] - - for rate, gross in grossvalue_map.items(): - if line.tax_rate == 0: - continue - tax = taxvalue_map[rate] - tdata.append(( - '', - localize(rate) + " %", - localize((gross - tax)) + " " + invoice.event.currency, - localize(gross) + " " + invoice.event.currency, - localize(tax) + " " + invoice.event.currency, - )) - - if len(tdata) > 2: - colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)] - table = Table(tdata, colWidths=colwidths, repeatRows=2) - table.setStyle(TableStyle(tstyledata)) - story.append(table) - - doc.build(story) - return doc - - @app.task(base=TransactionAwareTask) def invoice_pdf_task(invoice: int): i = Invoice.objects.get(pk=invoice) with language(i.locale): - with tempfile.NamedTemporaryFile(suffix=".pdf") as f: - _invoice_generate_german(i, f) - f.seek(0) - i.file.save('invoice.pdf', ContentFile(f.read())) + fname, ftype, fcontent = i.event.invoice_renderer.generate(i) + i.file.save(fname, ContentFile(fcontent)) i.save() return i.file.name @@ -452,7 +186,4 @@ def build_preview_invoice_pdf(event): gross_value=119, tax_value=19, tax_rate=19 ) - with tempfile.NamedTemporaryFile(suffix=".pdf") as f: - _invoice_generate_german(invoice, f) - f.seek(0) - return f.read() + return event.invoice_renderer.generate(invoice) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 72187c834..3979ce9ff 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -55,6 +55,10 @@ DEFAULTS = { 'default': 'True', 'type': bool, }, + 'invoice_renderer': { + 'default': 'classic', + 'type': str, + }, 'reservation_time': { 'default': '30', 'type': int diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 1f0eec923..b30271372 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -82,6 +82,16 @@ subclass of pretix.base.payment.BasePaymentProvider As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +register_invoice_renderers = EventPluginSignal( + providing_args=[] +) +""" +This signal is sent out to get all known invoice renderers. Receivers should return a +subclass of pretix.base.invoice.BaseInvoiceRenderer + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + register_ticket_outputs = EventPluginSignal( providing_args=[] ) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 417d01207..6a1e32050 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -436,6 +436,11 @@ class InvoiceSettingsForm(SettingsForm): ('paid', _('Automatically on payment')), ) ) + invoice_renderer = forms.ChoiceField( + label=_("Invoice style"), + required=True, + choices=[] + ) invoice_address_from = forms.CharField( widget=forms.Textarea(attrs={'rows': 5}), required=False, label=_("Your address"), @@ -472,6 +477,13 @@ class InvoiceSettingsForm(SettingsForm): help_text=_('We will show your logo with a maximal height and width of 2.5 cm.') ) + def __init__(self, *args, **kwargs): + event = kwargs.get('obj') + super().__init__(*args, **kwargs) + self.fields['invoice_renderer'].choices = [ + (r.identifier, r.verbose_name) for r in event.get_invoice_renderers().values() + ] + class MailSettingsForm(SettingsForm): mail_prefix = forms.CharField( diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index 189cef351..f8b5b49dc 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -12,6 +12,7 @@ {% bootstrap_field form.invoice_address_vatid layout="horizontal" %} {% bootstrap_field form.invoice_numbers_consecutive layout="horizontal" %} {% bootstrap_field form.invoice_generate layout="horizontal" %} + {% bootstrap_field form.invoice_renderer layout="horizontal" %} {% bootstrap_field form.invoice_language layout="horizontal" %} {% bootstrap_field form.invoice_address_from layout="horizontal" %} {% bootstrap_field form.invoice_introductory_text layout="horizontal" %} diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 1d0e602e9..3baa699c6 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -310,9 +310,9 @@ class InvoicePreview(EventPermissionRequiredMixin, View): permission = 'can_change_event_settings' def get(self, request, *args, **kwargs): - pdf = build_preview_invoice_pdf(request.event) - resp = HttpResponse(pdf, content_type='application/pdf') - resp['Content-Disposition'] = 'attachment; filename="invoice-preview.pdf"' + fname, ftype, fcontent = build_preview_invoice_pdf(request.event) + resp = HttpResponse(fcontent, content_type=ftype) + resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname) return resp