mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
1297 lines
57 KiB
Python
1297 lines
57 KiB
Python
#
|
||
# This file is part of pretix (Community Edition).
|
||
#
|
||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||
#
|
||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||
# this file, see <https://pretix.eu/about/en/license>.
|
||
#
|
||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||
# details.
|
||
#
|
||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||
# <https://www.gnu.org/licenses/>.
|
||
#
|
||
import datetime
|
||
import logging
|
||
import math
|
||
import re
|
||
import textwrap
|
||
import unicodedata
|
||
from collections import defaultdict
|
||
from decimal import Decimal
|
||
from io import BytesIO
|
||
from itertools import groupby
|
||
from typing import Tuple
|
||
|
||
import bleach
|
||
import vat_moss.exchange_rates
|
||
from bidi import get_display
|
||
from django.contrib.staticfiles import finders
|
||
from django.db.models import Sum
|
||
from django.dispatch import receiver
|
||
from django.utils.formats import date_format, localize
|
||
from django.utils.translation import (
|
||
get_language, gettext, gettext_lazy, pgettext,
|
||
)
|
||
from reportlab.lib import colors, pagesizes
|
||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||
from reportlab.lib.units import mm
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.pdfgen.canvas import Canvas
|
||
from reportlab.platypus import (
|
||
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
|
||
PageTemplate, Spacer, Table, TableStyle,
|
||
)
|
||
|
||
from pretix.base.decimal import round_decimal
|
||
from pretix.base.models import Event, Invoice, Order, OrderPayment
|
||
from pretix.base.services.currencies import SOURCE_NAMES
|
||
from pretix.base.signals import register_invoice_renderers
|
||
from pretix.base.templatetags.money import money_filter
|
||
from pretix.helpers.reportlab import (
|
||
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
|
||
)
|
||
from pretix.presale.style import get_fonts
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def addon_aware_groupby(iterable, key, is_addon):
|
||
"""
|
||
We use groupby() to visually group identical lines on an invoice. For example, instead of
|
||
|
||
Product 1 5.00 EUR
|
||
Product 1 5.00 EUR
|
||
Product 1 5.00 EUR
|
||
Product 2 7.00 EUR
|
||
|
||
We want to print
|
||
|
||
3x Product 1 5.00 EUR = 15.00 EUR
|
||
Product 2 7.00 EUR
|
||
|
||
However, this fails for setups with addon-products since groupby() only groups consecutive
|
||
lines with the same identity. So in
|
||
|
||
Product 1 5.00 EUR
|
||
+ Addon 1 2.00 EUR
|
||
Product 1 5.00 EUR
|
||
+ Addon 1 2.00 EUR
|
||
Product 1 5.00 EUR
|
||
+ Addon 2 3.00 EUR
|
||
|
||
There is no consecutive repetition of the same entity. This function provides a specialised groupby which
|
||
understands the product/addon relationship and packs groups of these addons together if they are, in fact,
|
||
identical groups:
|
||
|
||
2x Product 1 5.00 EUR = 10.00 EUR
|
||
+ 2x Addon 1 2.00 EUR = 4.00 EUR
|
||
Product 1 5.00 EUR
|
||
+ Addon 2 3.00 EUR
|
||
"""
|
||
packed_groups = []
|
||
|
||
for i in iterable:
|
||
if is_addon(i):
|
||
packed_groups[-1].append(i)
|
||
else:
|
||
packed_groups.append([i])
|
||
# Each packed_groups element contains a list with the parent product as first element, and any addon products following
|
||
|
||
def _reorder(packed_groups):
|
||
# Emit the products as individual products again, reordered by "all parent products, then all addon products"
|
||
# within each group.
|
||
for _, repeated_groups in groupby(packed_groups, key=lambda g: tuple(key(a) for a in g)):
|
||
for repeated_items in zip(*repeated_groups):
|
||
yield from repeated_items
|
||
|
||
return groupby(_reorder(packed_groups), key)
|
||
|
||
|
||
class NumberedCanvas(Canvas):
|
||
def __init__(self, *args, **kwargs):
|
||
self.font_regular = kwargs.pop('font_regular')
|
||
super().__init__(*args, **kwargs)
|
||
self._saved_page_states = []
|
||
|
||
def showPage(self):
|
||
self._saved_page_states.append(dict(self.__dict__))
|
||
self._startPage()
|
||
|
||
def save(self):
|
||
num_pages = len(self._saved_page_states)
|
||
for state in self._saved_page_states:
|
||
self.__dict__.update(state)
|
||
self.draw_page_number(num_pages)
|
||
Canvas.showPage(self)
|
||
Canvas.save(self)
|
||
|
||
def draw_page_number(self, page_count):
|
||
self.saveState()
|
||
self.setFont(self.font_regular, 8)
|
||
text = pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,)
|
||
try:
|
||
text = get_display(reshaper.reshape(text))
|
||
except:
|
||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, text)
|
||
self.restoreState()
|
||
|
||
|
||
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
|
||
canvas_class = Canvas
|
||
font_regular = 'OpenSans'
|
||
font_bold = 'OpenSansBd'
|
||
|
||
def _init(self):
|
||
"""
|
||
Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``.
|
||
"""
|
||
self._register_fonts()
|
||
self.stylesheet = self._get_stylesheet()
|
||
|
||
def _get_stylesheet(self):
|
||
"""
|
||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||
"""
|
||
stylesheet = StyleSheet1()
|
||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||
stylesheet.add(ParagraphStyle(name='Bold', fontName=self.font_bold, fontSize=10, leading=12))
|
||
stylesheet.add(ParagraphStyle(name='BoldRight', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT))
|
||
stylesheet.add(ParagraphStyle(name='BoldRightNoSplit', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT,
|
||
splitLongWords=False))
|
||
stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT))
|
||
stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12,
|
||
textColor=colors.white, alignment=TA_CENTER))
|
||
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
|
||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||
stylesheet.add(ParagraphStyle(name='FineprintRight', fontName=self.font_regular, fontSize=8, leading=10, alignment=TA_RIGHT))
|
||
stylesheet.add(ParagraphStyle(name='WarningBlock', fontName=self.font_bold, fontSize=10, leading=12,
|
||
alignment=TA_LEFT, borderWidth=1 * mm, borderColor=colors.black,
|
||
borderPadding=2 * mm, spaceBefore=5 * mm, spaceAfter=5 * mm))
|
||
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')
|
||
|
||
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||
if family == self.event.settings.invoice_renderer_font:
|
||
self.font_regular = family
|
||
if 'bold' in styles:
|
||
self.font_bold = family + ' B'
|
||
if 'italic' in styles:
|
||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||
if 'bold' in styles:
|
||
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
|
||
if 'bolditalic' in styles:
|
||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
||
|
||
def _normalize(self, text):
|
||
# reportlab does not support unicode combination characters
|
||
# It's important we do this before we use ArabicReshaper
|
||
text = unicodedata.normalize("NFKC", text)
|
||
|
||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||
try:
|
||
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
|
||
except:
|
||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||
|
||
return text
|
||
|
||
def _upper(self, val):
|
||
# We uppercase labels, but not in every language
|
||
if get_language().startswith('el'):
|
||
return val
|
||
return val.upper()
|
||
|
||
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, canvasmaker=self.canvas_class)
|
||
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()
|
||
|
||
def _clean_text(self, text, tags=None):
|
||
return self._normalize(bleach.clean(
|
||
text,
|
||
tags=set(tags) if tags else set()
|
||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
|
||
|
||
|
||
class PaidMarker(Flowable):
|
||
def __init__(self, text='paid', color=None, font='OpenSansBd', size=20):
|
||
super().__init__()
|
||
self.text = text
|
||
self.color = color
|
||
self.font = font
|
||
self.size = size
|
||
self._showBoundary = True
|
||
|
||
def wrap(self, availwidth, availheight):
|
||
# Fake a size, we don't care if we exceed the table
|
||
return 10, self.size / 2
|
||
|
||
def draw(self):
|
||
self.canv.translate(0, - self.size / 2)
|
||
self.canv.rotate(2)
|
||
self.canv.setFont(self.font, self.size)
|
||
self.canv.setFillColor(self.color)
|
||
width = self.canv.stringWidth(self.text, self.font, self.size)
|
||
self.canv.drawRightString(0, 0, self.text)
|
||
|
||
self.canv.setStrokeColor(self.color)
|
||
self.canv.roundRect(-width - self.size / 2, -self.size / 4, width + self.size, self.size + self.size / 4, 3)
|
||
|
||
|
||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||
identifier = 'classic'
|
||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||
|
||
def canvas_class(self, *args, **kwargs):
|
||
kwargs['font_regular'] = self.font_regular
|
||
return NumberedCanvas(*args, **kwargs)
|
||
|
||
def _on_other_page(self, canvas: Canvas, doc):
|
||
canvas.saveState()
|
||
canvas.setFont(self.font_regular, 8)
|
||
|
||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip()))
|
||
|
||
canvas.restoreState()
|
||
|
||
invoice_to_width = 85 * mm
|
||
invoice_to_height = 50 * mm
|
||
invoice_to_left = 25 * mm
|
||
invoice_to_top = 52 * mm
|
||
|
||
def _draw_invoice_to(self, canvas):
|
||
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
|
||
style=self.stylesheet['Normal'])
|
||
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
||
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
||
|
||
invoice_from_width = 70 * mm
|
||
invoice_from_height = 50 * mm
|
||
invoice_from_left = 25 * mm
|
||
invoice_from_top = 17 * mm
|
||
|
||
def _draw_invoice_from(self, canvas):
|
||
p = FontFallbackParagraph(
|
||
self._clean_text(self.invoice.full_invoice_from),
|
||
style=self.stylesheet['InvoiceFrom']
|
||
)
|
||
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
|
||
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height)
|
||
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
|
||
|
||
def _draw_invoice_from_label(self, canvas):
|
||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice from'))))
|
||
canvas.drawText(textobject)
|
||
|
||
def _draw_invoice_to_label(self, canvas):
|
||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice to'))))
|
||
canvas.drawText(textobject)
|
||
|
||
logo_width = 25 * mm
|
||
logo_height = 25 * mm
|
||
logo_left = 95 * mm
|
||
logo_top = 13 * mm
|
||
logo_anchor = 'n'
|
||
|
||
def _draw_logo(self, canvas):
|
||
if self.invoice.event.settings.invoice_logo_image:
|
||
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||
ir = ThumbnailingImageReader(logo_file)
|
||
try:
|
||
ir.resize(self.logo_width, self.logo_height, 300)
|
||
except:
|
||
logger.exception("Can not resize image")
|
||
pass
|
||
try:
|
||
# Valid ZUGFeRD invoices must be compliant with PDF/A-3. pretix-zugferd ensures this by passing them
|
||
# through ghost script. Unfortunately, if the logo contains transparency, this will still fail.
|
||
# I was unable to figure out a way to fix this in GhostScript, so the easy fix is to remove the
|
||
# transparency, as our invoices always have a white background anyways.
|
||
ir.remove_transparency()
|
||
except:
|
||
logger.exception("Can not remove transparency from logo")
|
||
pass
|
||
canvas.drawImage(ir,
|
||
self.logo_left,
|
||
self.pagesize[1] - self.logo_height - self.logo_top,
|
||
width=self.logo_width, height=self.logo_height,
|
||
preserveAspectRatio=True, anchor=self.logo_anchor,
|
||
mask='auto')
|
||
|
||
def _draw_metadata(self, canvas):
|
||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Order code'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(self.invoice.order.full_code))
|
||
canvas.drawText(textobject)
|
||
|
||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||
textobject.setFont(self.font_bold, 8)
|
||
if self.invoice.is_cancellation:
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation number'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(self.invoice.number))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(self.invoice.refers.number))
|
||
else:
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice number'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(self.invoice.number))
|
||
textobject.moveCursor(0, 5)
|
||
|
||
if self.invoice.is_cancellation:
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation date'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT")))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice date'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(date_format(self.invoice.refers.date, "DATE_FORMAT")))
|
||
textobject.moveCursor(0, 5)
|
||
else:
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice date'))))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_regular, 10)
|
||
textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT")))
|
||
textobject.moveCursor(0, 5)
|
||
|
||
canvas.drawText(textobject)
|
||
|
||
event_left = 125 * mm
|
||
event_top = 17 * mm
|
||
event_width = 65 * mm
|
||
event_height = 50 * mm
|
||
|
||
def _draw_event_label(self, canvas):
|
||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||
textobject.setFont(self.font_bold, 8)
|
||
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event'))))
|
||
canvas.drawText(textobject)
|
||
|
||
def _date_range_in_header(self):
|
||
if self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
|
||
return None, None
|
||
tz = self.invoice.event.timezone
|
||
show_end_date = (
|
||
self.invoice.event.settings.show_date_to and
|
||
self.invoice.event.date_to and
|
||
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
|
||
)
|
||
if show_end_date:
|
||
return self.invoice.event.date_from.astimezone(tz).date(), self.invoice.event.date_to.astimezone(tz).date()
|
||
else:
|
||
return self.invoice.event.date_from.astimezone(tz).date(), None
|
||
|
||
def _draw_event(self, canvas):
|
||
def shorten(txt):
|
||
txt = str(txt)
|
||
txt = bleach.clean(txt, tags=set()).strip()
|
||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||
p_size = p.wrap(self.event_width, self.event_height)
|
||
|
||
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
|
||
txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…'
|
||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||
p_size = p.wrap(self.event_width, self.event_height)
|
||
return txt
|
||
|
||
d_from, d_to = self._date_range_in_header()
|
||
if d_from and d_to:
|
||
p_str = (
|
||
shorten(self.invoice.event.name) + '\n' +
|
||
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||
from_date=date_format(d_from, "DATE_FORMAT"),
|
||
to_date=date_format(d_to, "DATE_FORMAT"),
|
||
)
|
||
)
|
||
elif d_from:
|
||
p_str = shorten(self.invoice.event.name) + '\n' + date_format(d_from, "DATE_FORMAT")
|
||
else:
|
||
p_str = shorten(self.invoice.event.name)
|
||
|
||
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||
p.wrapOn(canvas, self.event_width, self.event_height)
|
||
p_size = p.wrap(self.event_width, self.event_height)
|
||
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
|
||
self._draw_event_label(canvas)
|
||
|
||
def _draw_footer(self, canvas):
|
||
canvas.setFont(self.font_regular, 8)
|
||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip()))
|
||
|
||
def _draw_testmode(self, canvas):
|
||
if self.invoice.order.testmode:
|
||
canvas.saveState()
|
||
canvas.setFont(self.font_bold, 30)
|
||
canvas.setFillColorRGB(32, 0, 0)
|
||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, self._normalize(gettext('TEST MODE')))
|
||
canvas.restoreState()
|
||
|
||
def _draw_watermark(self, canvas):
|
||
watermark = self.invoice.transmission_type_instance.pdf_watermark()
|
||
if watermark:
|
||
canvas.saveState()
|
||
for font_size in range(200, 20, -10):
|
||
width = stringWidth(watermark, self.font_bold, font_size)
|
||
if width < self.pagesize[0]:
|
||
break
|
||
|
||
canvas.translate(self.pagesize[0] / 2, self.pagesize[1] / 2)
|
||
canvas.rotate(math.atan(self.pagesize[1] / self.pagesize[0]) / math.pi * 180)
|
||
canvas.setFont(self.font_bold, font_size)
|
||
canvas.setFillColorRGB(.92, .92, .92)
|
||
canvas.drawCentredString(0, - font_size / 2, self._normalize(watermark))
|
||
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()
|
||
self._draw_watermark(canvas)
|
||
self._draw_footer(canvas)
|
||
self._draw_testmode(canvas)
|
||
self._draw_invoice_from_label(canvas)
|
||
self._draw_invoice_from(canvas)
|
||
self._draw_invoice_to_label(canvas)
|
||
self._draw_invoice_to(canvas)
|
||
self._draw_metadata(canvas)
|
||
self._draw_logo(canvas)
|
||
self._draw_event(canvas)
|
||
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_intro(self):
|
||
story = []
|
||
|
||
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
|
||
if type_info_text:
|
||
story.append(FontFallbackParagraph(
|
||
type_info_text,
|
||
self.stylesheet['WarningBlock']
|
||
))
|
||
|
||
if self.invoice.custom_field:
|
||
story.append(FontFallbackParagraph(
|
||
'{}: {}'.format(
|
||
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
|
||
self._clean_text(self.invoice.custom_field),
|
||
),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.internal_reference:
|
||
story.append(FontFallbackParagraph(
|
||
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
|
||
reference=self._clean_text(self.invoice.internal_reference),
|
||
)),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.invoice_to_vat_id:
|
||
story.append(FontFallbackParagraph(
|
||
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
|
||
self._clean_text(self.invoice.invoice_to_vat_id),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.invoice_to_beneficiary:
|
||
story.append(FontFallbackParagraph(
|
||
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
|
||
self._clean_text(self.invoice.invoice_to_beneficiary),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.introductory_text:
|
||
# While all intro fields are appended without any blank lines; we do want one before the optional intro
|
||
# text. However, if there are no prior intro fields, adding an additional spacer will waste space.
|
||
if story:
|
||
story.append(Spacer(1, 5 * mm))
|
||
|
||
story.append(FontFallbackParagraph(
|
||
self._clean_text(self.invoice.introductory_text, tags=['br']),
|
||
self.stylesheet['Normal']
|
||
))
|
||
story.append(Spacer(1, 5 * mm))
|
||
|
||
return story
|
||
|
||
def _get_story(self, doc):
|
||
all_lines = list(self.invoice.lines.all())
|
||
has_taxes = any(il.tax_value for il in all_lines) or self.invoice.reverse_charge
|
||
header_dates = self._date_range_in_header()
|
||
tz = self.invoice.event.timezone
|
||
has_multiple_service_dates = len(set(
|
||
(il.period_start, il.period_end) for il in all_lines
|
||
)) > 1
|
||
request_show_service_date = False
|
||
|
||
story = [
|
||
NextPageTemplate('FirstPage'),
|
||
FontFallbackParagraph(
|
||
self._normalize(
|
||
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
|
||
else pgettext('invoice', 'Invoice')
|
||
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
|
||
self.stylesheet['Heading1']
|
||
),
|
||
Spacer(1, 5 * mm),
|
||
NextPageTemplate('OtherPages'),
|
||
]
|
||
story += self._get_intro()
|
||
|
||
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, -1), self.font_regular),
|
||
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
|
||
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
|
||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||
]
|
||
if has_taxes:
|
||
tdata = [(
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
|
||
)]
|
||
else:
|
||
tdata = [(
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||
)]
|
||
|
||
def _group_key(line):
|
||
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent,
|
||
line.period_start, line.period_end)
|
||
|
||
def day(dt: datetime.datetime) -> datetime.date:
|
||
if dt is None:
|
||
return None
|
||
return dt.astimezone(tz).date()
|
||
|
||
total = Decimal('0.00')
|
||
if has_taxes:
|
||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||
else:
|
||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
||
|
||
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
|
||
all_lines,
|
||
key=_group_key,
|
||
is_addon=lambda l: l.description.startswith(" +"),
|
||
):
|
||
# split description into multiple Paragraphs so each fits in a table cell on a single page
|
||
# otherwise PDF-build fails
|
||
|
||
description_p_list = []
|
||
# normalize linebreaks to newlines instead of HTML so we can safely substring
|
||
description = description.replace('<br>', '<br />').replace('<br />\n', '\n').replace('<br />', '\n')
|
||
|
||
# start first line with different settings than the rest of the description
|
||
curr_description = description.split("\n", maxsplit=1)[0]
|
||
cellpadding = 6 # default cellpadding is only set on right side of column
|
||
max_width = colwidths[0] - cellpadding
|
||
max_height = self.stylesheet['Normal'].leading * 5
|
||
p_style = self.stylesheet['Normal']
|
||
for __ in range(1000):
|
||
p = FontFallbackParagraph(
|
||
self._clean_text(curr_description, tags=['br']),
|
||
p_style
|
||
)
|
||
h = p.wrap(max_width, doc.height)[1]
|
||
if h <= max_height:
|
||
description_p_list.append(p)
|
||
if curr_description == description:
|
||
break
|
||
description = description[len(curr_description):].lstrip()
|
||
curr_description = description.split("\n", maxsplit=1)[0]
|
||
# use different settings for all except first line
|
||
max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
|
||
max_height = self.stylesheet['Fineprint'].leading * 8
|
||
p_style = self.stylesheet['Fineprint']
|
||
continue
|
||
|
||
if not description_p_list:
|
||
# first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
|
||
max_height = self.stylesheet['Normal'].leading
|
||
|
||
if h > max_height * 1.1:
|
||
# quickly bring the text-length down to a managable length to then stepwise reduce
|
||
wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
|
||
else:
|
||
# trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
|
||
wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
|
||
curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
|
||
|
||
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
|
||
# not perfect and the only "fully correct" way would be to include the period on every line always,
|
||
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
|
||
# (b) due to not respecting the show_date_to setting of events in cases where we could have respected it.
|
||
# Still, we want to show the date explicitly if its different to the event or invoice date.
|
||
period_start_day = day(period_start)
|
||
period_end_day = day(period_end)
|
||
if period_start and period_end and period_end_day != period_start_day:
|
||
# It's a multi-day period, such as the validity of the ticket or an event date period
|
||
|
||
if period_start_day == header_dates[0] and period_end_day == header_dates[1]:
|
||
# This is the exact event period we already printed in the header, no need to repeat it.
|
||
period_line = ""
|
||
|
||
elif (self.event.has_subevents and subevent and day(subevent.date_from) == period_start_day and
|
||
day(subevent.date_to) == period_end_day):
|
||
# For subevents, build_invoice already includes the date in the description in the event-default format.
|
||
period_line = ""
|
||
|
||
else:
|
||
period_line = f"{date_format(period_start_day, 'SHORT_DATE_FORMAT')} – {date_format(period_end_day, 'SHORT_DATE_FORMAT')}"
|
||
|
||
elif period_start or period_end:
|
||
# It's a single-day period
|
||
|
||
delivery_day = period_end_day or period_start_day
|
||
if delivery_day in header_dates:
|
||
# This is the event date we already printed in the header, no need to repeat it.
|
||
period_line = ""
|
||
|
||
elif self.event.has_subevents and subevent and delivery_day in (day(subevent.date_from), day(subevent.date_to)):
|
||
# For subevents, build_invoice already includes the date in the description in the event-default format.
|
||
period_line = ""
|
||
|
||
elif (delivery_day == self.invoice.date) and header_dates[0] is None:
|
||
# This is a shop that doesn't show the date of the event in the header, and the period is the invoice
|
||
# date. We assume that this is an 'everything is executed immediately' situation and do not want to
|
||
# confuse with showing additional dates on the invoice. This is the case that is not guaranteed to be
|
||
# correct in all cases and might need to change in the future. If customers have legal concerns, a
|
||
# quick fix is including a sentence like "Delivery date is the invoice date unless otherwise indicated:"
|
||
# in a custom text on the invoice.
|
||
period_line = ""
|
||
|
||
else:
|
||
period_line = date_format(delivery_day, 'SHORT_DATE_FORMAT')
|
||
else:
|
||
# No period known
|
||
period_line = ""
|
||
|
||
if not has_multiple_service_dates and period_line:
|
||
# Group together at the end of the invoice
|
||
request_show_service_date = period_line
|
||
elif period_line:
|
||
description_p_list.append(FontFallbackParagraph(
|
||
period_line,
|
||
self.stylesheet['Fineprint']
|
||
))
|
||
|
||
lines = list(lines)
|
||
if has_taxes:
|
||
if len(lines) > 1:
|
||
single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format(
|
||
net_price=money_filter(net_value, self.invoice.event.currency),
|
||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||
)
|
||
description_p_list.append(FontFallbackParagraph(
|
||
single_price_line,
|
||
self.stylesheet['Fineprint']
|
||
))
|
||
|
||
tdata.append((
|
||
description_p_list.pop(0),
|
||
str(len(lines)),
|
||
localize(tax_rate) + " %",
|
||
FontFallbackParagraph(
|
||
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||
self.stylesheet['NormalRight']
|
||
),
|
||
FontFallbackParagraph(
|
||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||
self.stylesheet['NormalRight']
|
||
),
|
||
))
|
||
for p in description_p_list:
|
||
tdata.append((p, "", "", "", ""))
|
||
tstyledata.append((
|
||
'SPAN',
|
||
(0, len(tdata) - 1),
|
||
(2, len(tdata) - 1),
|
||
))
|
||
else:
|
||
if len(lines) > 1:
|
||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||
price=money_filter(gross_value, self.invoice.event.currency),
|
||
)
|
||
description_p_list.append(FontFallbackParagraph(
|
||
single_price_line,
|
||
self.stylesheet['Fineprint']
|
||
))
|
||
tdata.append((
|
||
description_p_list.pop(0),
|
||
str(len(lines)),
|
||
FontFallbackParagraph(
|
||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||
self.stylesheet['NormalRight']
|
||
),
|
||
))
|
||
for p in description_p_list:
|
||
tdata.append((p, "", ""))
|
||
tstyledata.append((
|
||
'SPAN',
|
||
(0, len(tdata) - 1),
|
||
(1, len(tdata) - 1),
|
||
))
|
||
|
||
tstyledata += [
|
||
(
|
||
'BOTTOMPADDING',
|
||
(0, len(tdata) - len(description_p_list)),
|
||
(-1, len(tdata) - 2),
|
||
0
|
||
),
|
||
(
|
||
'TOPPADDING',
|
||
(0, len(tdata) - len(description_p_list)),
|
||
(-1, len(tdata) - 1),
|
||
0
|
||
),
|
||
]
|
||
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
|
||
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
|
||
total += gross_value * len(lines)
|
||
|
||
if has_taxes:
|
||
tdata.append([
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
||
money_filter(total, self.invoice.event.currency)
|
||
])
|
||
else:
|
||
tdata.append([
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
||
money_filter(total, self.invoice.event.currency)
|
||
])
|
||
|
||
if not self.invoice.is_cancellation:
|
||
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
||
pending_sum = self.invoice.order.pending_sum
|
||
if pending_sum != total:
|
||
tdata.append(
|
||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
|
||
(['', '', ''] if has_taxes else ['']) +
|
||
[money_filter(pending_sum - total, self.invoice.event.currency)]
|
||
)
|
||
tdata.append(
|
||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
|
||
(['', '', ''] if has_taxes else ['']) +
|
||
[money_filter(pending_sum, self.invoice.event.currency)]
|
||
)
|
||
tstyledata += [
|
||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||
]
|
||
elif self.invoice.event.settings.invoice_show_payments and self.invoice.order.payments.filter(
|
||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard'
|
||
).exists():
|
||
giftcard_sum = self.invoice.order.payments.filter(
|
||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||
provider='giftcard'
|
||
).aggregate(
|
||
s=Sum('amount')
|
||
)['s'] or Decimal('0.00')
|
||
tdata.append(
|
||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
|
||
(['', '', ''] if has_taxes else ['']) +
|
||
[money_filter(giftcard_sum, self.invoice.event.currency)]
|
||
)
|
||
tdata.append(
|
||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
|
||
(['', '', ''] if has_taxes else ['']) +
|
||
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
|
||
)
|
||
tstyledata += [
|
||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||
]
|
||
elif self.invoice.payment_provider_stamp:
|
||
pm = PaidMarker(
|
||
text=self._normalize(self.invoice.payment_provider_stamp),
|
||
color=colors.HexColor(self.event.settings.theme_color_success),
|
||
font=self.font_bold,
|
||
size=16
|
||
)
|
||
tdata[-1][-2] = pm
|
||
|
||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||
table.setStyle(TableStyle(tstyledata))
|
||
story.append(table)
|
||
|
||
story.append(Spacer(1, 10 * mm))
|
||
|
||
if request_show_service_date:
|
||
story.append(FontFallbackParagraph(
|
||
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.payment_provider_text:
|
||
story.append(FontFallbackParagraph(
|
||
self._normalize(self.invoice.payment_provider_text),
|
||
self.stylesheet['Normal']
|
||
))
|
||
|
||
if self.invoice.payment_provider_text and self.invoice.additional_text:
|
||
story.append(Spacer(1, 3 * mm))
|
||
|
||
if self.invoice.additional_text:
|
||
story.append(FontFallbackParagraph(
|
||
self._clean_text(self.invoice.additional_text, tags=['br']),
|
||
self.stylesheet['Normal']
|
||
))
|
||
story.append(Spacer(1, 5 * mm))
|
||
|
||
tstyledata = [
|
||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||
('TOPPADDING', (0, 0), (-1, -1), 1),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 1),
|
||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||
]
|
||
thead = [
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
|
||
''
|
||
]
|
||
tdata = [thead]
|
||
|
||
for idx, gross in grossvalue_map.items():
|
||
rate, name = idx
|
||
if rate == 0 and gross == 0:
|
||
continue
|
||
tax = taxvalue_map[idx]
|
||
tdata.append([
|
||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
||
money_filter(gross - tax, self.invoice.event.currency),
|
||
money_filter(gross, self.invoice.event.currency),
|
||
money_filter(tax, self.invoice.event.currency),
|
||
''
|
||
])
|
||
|
||
def fmt(val):
|
||
try:
|
||
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
|
||
except ValueError:
|
||
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
||
|
||
if any(rate != 0 and gross != 0 for (rate, name), gross in grossvalue_map.items()) and has_taxes:
|
||
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
|
||
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
|
||
table.setStyle(TableStyle(tstyledata))
|
||
story.append(Spacer(5 * mm, 5 * mm))
|
||
story.append(KeepTogether([
|
||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
|
||
table
|
||
]))
|
||
|
||
if self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||
tdata = [thead]
|
||
|
||
for idx, gross in grossvalue_map.items():
|
||
rate, name = idx
|
||
if rate == 0:
|
||
continue
|
||
tax = taxvalue_map[idx]
|
||
gross = round_decimal(gross * self.invoice.foreign_currency_rate)
|
||
tax = round_decimal(tax * self.invoice.foreign_currency_rate)
|
||
net = gross - tax
|
||
|
||
tdata.append([
|
||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
||
fmt(net), fmt(gross), fmt(tax), ''
|
||
])
|
||
|
||
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
|
||
table.setStyle(TableStyle(tstyledata))
|
||
|
||
story.append(KeepTogether([
|
||
Spacer(1, height=2 * mm),
|
||
FontFallbackParagraph(
|
||
self._normalize(pgettext(
|
||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||
'{date}, this corresponds to:'
|
||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
|
||
self.stylesheet['Fineprint']
|
||
),
|
||
Spacer(1, height=3 * mm),
|
||
table
|
||
]))
|
||
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
||
story.append(Spacer(1, 5 * mm))
|
||
story.append(FontFallbackParagraph(self._normalize(
|
||
pgettext(
|
||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||
'{date}, the invoice total corresponds to {total}.'
|
||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||
total=fmt(foreign_total))),
|
||
self.stylesheet['Fineprint']
|
||
))
|
||
|
||
return story
|
||
|
||
|
||
class Modern1Renderer(ClassicInvoiceRenderer):
|
||
identifier = 'modern1'
|
||
verbose_name = gettext_lazy('Default invoice renderer (European-style letter)')
|
||
bottom_margin = 16.9 * mm
|
||
top_margin = 16.9 * mm
|
||
right_margin = 20 * mm
|
||
invoice_to_height = 27.3 * mm
|
||
invoice_to_width = 80 * mm
|
||
invoice_to_left = 25 * mm
|
||
invoice_to_top = (40 + 17.7) * mm
|
||
invoice_from_left = 125 * mm
|
||
invoice_from_top = 50 * mm
|
||
invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin
|
||
invoice_from_height = 50 * mm
|
||
|
||
logo_width = 75 * mm
|
||
logo_height = 25 * mm
|
||
logo_left = pagesizes.A4[0] - logo_width - right_margin
|
||
logo_top = top_margin
|
||
logo_anchor = 'e'
|
||
|
||
event_left = 25 * mm
|
||
event_top = top_margin
|
||
event_width = 80 * mm
|
||
event_height = 25 * mm
|
||
|
||
def _get_stylesheet(self):
|
||
stylesheet = super()._get_stylesheet()
|
||
stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10))
|
||
stylesheet['InvoiceFrom'].alignment = TA_RIGHT
|
||
return stylesheet
|
||
|
||
def _draw_invoice_from(self, canvas):
|
||
if not self.invoice.invoice_from:
|
||
return
|
||
c = [
|
||
self._clean_text(l)
|
||
for l in self.invoice.address_invoice_from.strip().split('\n')
|
||
]
|
||
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
|
||
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
|
||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
|
||
super()._draw_invoice_from(canvas)
|
||
|
||
def _draw_invoice_to_label(self, canvas):
|
||
pass
|
||
|
||
def _draw_invoice_from_label(self, canvas):
|
||
pass
|
||
|
||
def _draw_event_label(self, canvas):
|
||
pass
|
||
|
||
def _get_first_page_frames(self, doc):
|
||
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
|
||
if self.event.settings.invoice_renderer_highlight_order_code:
|
||
margin_top = 100 * mm
|
||
else:
|
||
margin_top = 95 * mm
|
||
return [
|
||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - margin_top,
|
||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||
id='normal')
|
||
]
|
||
|
||
def _draw_metadata(self, canvas):
|
||
# Draws the "invoice number -- date" line. This has gotten a little more complicated since we
|
||
# encountered some events with very long invoice numbers. In this case, we automatically reduce
|
||
# the font size until it fits.
|
||
begin_top = 100 * mm
|
||
|
||
def _draw(label, value, value_size, x, width, bold=False, sublabel=None):
|
||
if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6:
|
||
return False
|
||
textobject = canvas.beginText(x, self.pagesize[1] - begin_top)
|
||
textobject.setFont(self.font_regular, 8)
|
||
textobject.textLine(self._normalize(label))
|
||
textobject.moveCursor(0, 5)
|
||
textobject.setFont(self.font_bold if bold else self.font_regular, value_size)
|
||
textobject.textLine(self._normalize(value))
|
||
|
||
if sublabel:
|
||
textobject.moveCursor(0, 1)
|
||
textobject.setFont(self.font_regular, 8)
|
||
textobject.textLine(self._normalize(sublabel))
|
||
|
||
return textobject
|
||
|
||
value_size = 10
|
||
while value_size >= 5:
|
||
if self.event.settings.invoice_renderer_highlight_order_code:
|
||
kwargs = dict(bold=True, sublabel=pgettext('invoice', '(Please quote at all times.)'))
|
||
else:
|
||
kwargs = {}
|
||
objects = [
|
||
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
|
||
]
|
||
|
||
p = FontFallbackParagraph(
|
||
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
|
||
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
||
)
|
||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||
p.wrapOn(canvas, w, 15 * mm)
|
||
date_x = self.pagesize[0] - w - self.right_margin
|
||
|
||
if self.invoice.is_cancellation:
|
||
objects += [
|
||
_draw(pgettext('invoice', 'Cancellation number'), self.invoice.number,
|
||
value_size, self.left_margin + 50 * mm, 45 * mm),
|
||
_draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number,
|
||
value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm),
|
||
]
|
||
else:
|
||
objects += [
|
||
_draw(pgettext('invoice', 'Invoice number'), self.invoice.number,
|
||
value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm),
|
||
]
|
||
|
||
if all(objects):
|
||
for o in objects:
|
||
canvas.drawText(o)
|
||
break
|
||
value_size -= 1
|
||
|
||
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
|
||
|
||
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
|
||
textobject.setFont(self.font_regular, 8)
|
||
if self.invoice.is_cancellation:
|
||
textobject.textLine(self._normalize(pgettext('invoice', 'Cancellation date')))
|
||
else:
|
||
textobject.textLine(self._normalize(pgettext('invoice', 'Invoice date')))
|
||
canvas.drawText(textobject)
|
||
|
||
|
||
class Modern1SimplifiedRenderer(Modern1Renderer):
|
||
identifier = 'modern1simplified'
|
||
verbose_name = gettext_lazy('Simplified invoice renderer')
|
||
|
||
logo_left = Modern1Renderer.left_margin
|
||
logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left
|
||
logo_height = 25 * mm
|
||
logo_top = 13 * mm
|
||
logo_anchor = 'nw'
|
||
|
||
def _draw_invoice_from(self, canvas):
|
||
super(Modern1Renderer, self)._draw_invoice_from(canvas)
|
||
|
||
def _draw_event(self, canvas):
|
||
pass
|
||
|
||
def _get_intro(self):
|
||
i = []
|
||
|
||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||
i.append(FontFallbackParagraph(
|
||
pgettext('invoice', 'Event date: {date_range}').format(
|
||
date_range=self.invoice.event.get_date_range_display(),
|
||
),
|
||
self.stylesheet['Normal'],
|
||
))
|
||
i.append(Spacer(2 * mm, 2 * mm))
|
||
|
||
return i + super()._get_intro()
|
||
|
||
|
||
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
|
||
def recv_classic(sender, **kwargs):
|
||
return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer]
|