diff --git a/README.rst b/README.rst
index 919de97e9..4a6bfb99b 100644
--- a/README.rst
+++ b/README.rst
@@ -8,6 +8,7 @@ pretix
:target: https://docs.pretix.eu/
.. image:: https://github.com/pretix/pretix/workflows/Tests/badge.svg
+ :target: https://github.com/pretix/pretix/actions/workflows/tests.yml
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
:target: https://codecov.io/gh/pretix/pretix
diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst
index 16e36424d..89ab1cff1 100644
--- a/doc/api/resources/checkin.rst
+++ b/doc/api/resources/checkin.rst
@@ -359,3 +359,65 @@ Performing a ticket search
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested check-in list does not exist.
+
+.. _`rest-checkin-annul`:
+
+Annulment of a check-in
+-----------------------
+
+.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/
+
+ If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used
+ in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for
+ automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is
+ opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of
+ order.
+
+ This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each
+ check-in list passed needs to be from a distinct event.
+
+ Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than
+ 15 minutes after the datetime of check-in (value subject to change).
+
+ A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when
+ multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device,
+ the check-in is already in an annulled or failed state, or the datetime constraint is not valid.
+
+ : " + link + " " + escape(msg) + "
".join(get_display(reshaper.reshape(l)) for l in re.split("
", 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('
', '
').replace('\n', '
\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 = Paragraph(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 = Paragraph(
- 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 _draw_event(self, canvas):
- def shorten(txt):
- txt = str(txt)
- txt = bleach.clean(txt, tags=set()).strip()
- p = Paragraph(self._normalize(txt.strip().replace('\n', '
\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 = Paragraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal'])
- p_size = p.wrap(self.event_width, self.event_height)
- return txt
-
- if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
- 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:
- p_str = (
- shorten(self.invoice.event.name) + '\n' +
- pgettext('invoice', '{from_date}\nuntil {to_date}').format(
- from_date=self.invoice.event.get_date_from_display(show_times=False),
- to_date=self.invoice.event.get_date_to_display(show_times=False)
- )
- )
- else:
- p_str = (
- shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
- )
- else:
- p_str = shorten(self.invoice.event.name)
-
- p = Paragraph(self._normalize(p_str.strip().replace('\n', '
\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 _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_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 = []
- if self.invoice.custom_field:
- story.append(Paragraph(
- '{}: {}'.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(Paragraph(
- 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(Paragraph(
- 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(Paragraph(
- self._normalize(pgettext('invoice', 'Beneficiary')) + ':
' +
- self._clean_text(self.invoice.invoice_to_beneficiary),
- self.stylesheet['Normal']
- ))
-
- if self.invoice.introductory_text:
- story.append(Paragraph(
- self._clean_text(self.invoice.introductory_text, tags=['br']),
- self.stylesheet['Normal']
- ))
- story.append(Spacer(1, 10 * mm))
-
- return story
-
- def _get_story(self, doc):
- has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
-
- story = [
- NextPageTemplate('FirstPage'),
- Paragraph(
- 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 = [(
- Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
- Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
- Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
- Paragraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
- Paragraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
- )]
- else:
- tdata = [(
- Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
- Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
- Paragraph(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_id,
- line.event_date_from, line.event_date_to)
-
- total = Decimal('0.00')
- for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby(
- self.invoice.lines.all(),
- key=_group_key,
- is_addon=lambda l: l.description.startswith(" +"),
- ):
- 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 = description + "\n" + single_price_line
- tdata.append((
- Paragraph(
- self._clean_text(description, tags=['br']),
- self.stylesheet['Normal']
- ),
- str(len(lines)),
- localize(tax_rate) + " %",
- Paragraph(money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
- Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
- ))
- else:
- if len(lines) > 1:
- single_price_line = pgettext('invoice', 'Single price: {price}').format(
- price=money_filter(gross_value, self.invoice.event.currency),
- )
- description = description + "\n" + single_price_line
- tdata.append((
- Paragraph(
- self._clean_text(description, tags=['br']),
- self.stylesheet['Normal']
- ),
- str(len(lines)),
- Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
- ))
- 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([
- Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
- money_filter(total, self.invoice.event.currency)
- ])
- colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
- else:
- tdata.append([
- Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
- money_filter(total, self.invoice.event.currency)
- ])
- colwidths = [a * doc.width for a in (.65, .20, .15)]
-
- 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(
- [Paragraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
- (['', '', ''] if has_taxes else ['']) +
- [money_filter(pending_sum - total, self.invoice.event.currency)]
- )
- tdata.append(
- [Paragraph(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(
- [Paragraph(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(
- [Paragraph(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 self.invoice.payment_provider_text:
- story.append(Paragraph(
- 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(Paragraph(
- 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 = [
- Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
- Paragraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
- Paragraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
- Paragraph(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([
- Paragraph(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([
- Paragraph(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([
- Paragraph(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),
- Paragraph(
- 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(Paragraph(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 = Paragraph(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 = Paragraph(
- 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(Paragraph(
- 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]
+# This module consists for backwards compatibility of imports from plugins.
+__all__ = [
+ "addon_aware_groupby",
+ "NumberedCanvas",
+ "BaseInvoiceRenderer",
+ "BaseReportlabInvoiceRenderer",
+ "ClassicInvoiceRenderer",
+ "Modern1Renderer",
+ "Modern1SimplifiedRenderer",
+ "ThumbnailingImageReader",
+]
diff --git a/src/pretix/base/invoicing/__init__.py b/src/pretix/base/invoicing/__init__.py
new file mode 100644
index 000000000..9fd5bdc50
--- /dev/null
+++ b/src/pretix/base/invoicing/__init__.py
@@ -0,0 +1,21 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io 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
".join(get_display(reshaper.reshape(l)) for l in re.split("
", 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('
', '
').replace('\n', '
\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 _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', '
\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', '
\n')), style=self.stylesheet['Normal'])
+ p_size = p.wrap(self.event_width, self.event_height)
+ return txt
+
+ if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
+ 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:
+ p_str = (
+ shorten(self.invoice.event.name) + '\n' +
+ pgettext('invoice', '{from_date}\nuntil {to_date}').format(
+ from_date=self.invoice.event.get_date_from_display(show_times=False),
+ to_date=self.invoice.event.get_date_to_display(show_times=False)
+ )
+ )
+ else:
+ p_str = (
+ shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
+ )
+ else:
+ p_str = shorten(self.invoice.event.name)
+
+ p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '
\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 _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_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 = []
+ 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')) + ':
' +
+ 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):
+ has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
+
+ 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_id,
+ line.event_date_from, line.event_date_to)
+
+ total = Decimal('0.00')
+ for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby(
+ self.invoice.lines.all(),
+ key=_group_key,
+ is_addon=lambda l: l.description.startswith(" +"),
+ ):
+ 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 = description + "\n" + single_price_line
+ tdata.append((
+ FontFallbackParagraph(
+ self._clean_text(description, tags=['br']),
+ self.stylesheet['Normal']
+ ),
+ 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']
+ ),
+ ))
+ else:
+ if len(lines) > 1:
+ single_price_line = pgettext('invoice', 'Single price: {price}').format(
+ price=money_filter(gross_value, self.invoice.event.currency),
+ )
+ description = description + "\n" + single_price_line
+ tdata.append((
+ FontFallbackParagraph(
+ self._clean_text(description, tags=['br']),
+ self.stylesheet['Normal']
+ ),
+ str(len(lines)),
+ FontFallbackParagraph(
+ money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
+ self.stylesheet['NormalRight']
+ ),
+ ))
+ 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)
+ ])
+ colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
+ else:
+ tdata.append([
+ FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
+ money_filter(total, self.invoice.event.currency)
+ ])
+ colwidths = [a * doc.width for a in (.65, .20, .15)]
+
+ 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 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]
diff --git a/src/pretix/base/invoicing/peppol.py b/src/pretix/base/invoicing/peppol.py
new file mode 100644
index 000000000..805080590
--- /dev/null
+++ b/src/pretix/base/invoicing/peppol.py
@@ -0,0 +1,167 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io 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
{{ event.name }}
- {% if not event.has_subevents and event.settings.show_dates_on_frontpage %}
-
diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py
index 303578ec7..693dc0b71 100644
--- a/src/pretix/base/views/js_helpers.py
+++ b/src/pretix/base/views/js_helpers.py
@@ -21,38 +21,107 @@
#
import pycountry
from django.http import JsonResponse
+from django.shortcuts import get_object_or_404
from django.utils.translation import pgettext
+from django_countries.fields import Country
+from django_scopes import scope
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
+from pretix.base.invoicing.transmission import get_transmission_types
+from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
)
-def states(request):
- cc = request.GET.get("country", "DE")
+def _info(cc):
info = {
- 'street': {'required': True},
- 'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
- 'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
+ 'street': {'required': 'if_any'},
+ 'zipcode': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
+ 'city': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
'state': {
'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
- 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
+ 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False,
'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')),
},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
- return JsonResponse({'data': [], **info, })
+ return {'data': [], **info}
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
- return JsonResponse({
+ return {
'data': [
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
- })
+ }
+
+
+def address_form(request):
+ cc = request.GET.get("country", "DE")
+ info = _info(cc)
+
+ if request.GET.get("invoice") == "true":
+ # Do not consider live=True, as this does not expose sensitive information and we also want it accessible
+ # from e.g. the backend when the event is not yet life.
+ organizer = get_object_or_404(Organizer, slug=request.GET.get("organizer"))
+ with (scope(organizer=organizer)):
+ event = get_object_or_404(organizer.events, slug=request.GET.get("event"))
+ country = Country(cc)
+ is_business = request.GET.get("is_business") == "business"
+ selected_transmission_type = request.GET.get("transmission_type")
+ transmission_type_required = request.GET.get("transmission_type_required") == "true"
+
+ info["transmission_types"] = []
+
+ for t in get_transmission_types():
+ if t.is_available(event=event, country=country, is_business=is_business):
+ result = {"name": str(t.public_name), "code": t.identifier}
+ if t.exclusive:
+ info["transmission_types"] = [result]
+ break
+ else:
+ info["transmission_types"].append(result)
+
+ info["transmission_type"] = {
+ # Hide transmission type if email is the only type since that's basically the backwards-compatible
+ # option
+ "visible": [t["code"] for t in info["transmission_types"]] != ["email"],
+ }
+ if selected_transmission_type not in [t["code"] for t in info["transmission_types"]]:
+ if transmission_type_required:
+ # The previously selected transmission type is no longer selectable, e.g. because
+ # of a country change. To avoid a second roundtrip to this endpoint, let's show
+ # the fields as if the first remaining option were selected (which is what the client
+ # side will now do).
+ selected_transmission_type = info["transmission_types"][0]["code"]
+ else:
+ selected_transmission_type = "-"
+
+ for transmission_type in get_transmission_types():
+ required = transmission_type.invoice_address_form_fields_required(
+ country=country,
+ is_business=is_business
+ )
+ visible = transmission_type.invoice_address_form_fields_visible(
+ country=country,
+ is_business=is_business
+ )
+ if transmission_type.identifier == selected_transmission_type:
+ for k, v in info.items():
+ if k in required:
+ v["required"] = True
+ if k in visible:
+ v["visible"] = True
+ for k, f in transmission_type.invoice_address_form_fields.items():
+ info[k] = {
+ "visible": transmission_type.identifier == selected_transmission_type and k in visible,
+ "required": transmission_type.identifier == selected_transmission_type and k in required
+ }
+
+ return JsonResponse(info)
diff --git a/src/pretix/base/views/webmanifest.py b/src/pretix/base/views/webmanifest.py
new file mode 100644
index 000000000..22a31699a
--- /dev/null
+++ b/src/pretix/base/views/webmanifest.py
@@ -0,0 +1,52 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io 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
- {{ event.get_date_range_display }}
- {% if event.settings.show_times %}
- {{ event.date_from|date:"TIME_FORMAT" }}
+ {% if not event.has_subevents %}
+ {% if event.settings.show_dates_on_frontpage %}
+
+ {{ event.get_date_range_display }}
+ {% if event.settings.show_times %}
+ {{ event.date_from|date:"TIME_FORMAT" }}
+ {% endif %}
+ {% endif %}
+ {% if event.location %}
+
+ {{ event.location|oneline }}
{% endif %}
{% endif %}
{}'.format(a.verbose_name, a.action_type))
+ format_html('{} – {}
{}', a.verbose_name, a.action_type, a.help_text)
+ if a.help_text else
+ format_html('{} – {}', a.verbose_name, a.action_type)
) for a in get_all_webhook_events().values()
]
if self.instance and self.instance.pk:
@@ -778,7 +783,6 @@ class GiftCardUpdateForm(forms.ModelForm):
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
'organizer': organizer.slug,
}),
- 'data-placeholder': _('Ticket')
}
)
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
@@ -814,7 +818,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
'organizer': organizer.slug,
}),
- 'data-placeholder': _('Ticket')
}
)
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
@@ -827,7 +830,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
'organizer': organizer.slug,
}),
- 'data-placeholder': _('Gift card')
}
)
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
@@ -841,7 +843,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
'organizer': organizer.slug,
}),
- 'data-placeholder': _('Customer')
}
)
self.fields['customer'].widget.choices = self.fields['customer'].choices
@@ -1205,3 +1206,19 @@ class SalesChannelForm(I18nModelForm):
)
return d
+
+
+class OrganizerPluginEventsForm(forms.Form):
+ events = SafeEventMultipleChoiceField(
+ queryset=Event.objects.none(),
+ widget=forms.CheckboxSelectMultiple(attrs={
+ 'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
+ }),
+ label=_("Events with active plugin"),
+ required=False,
+ )
+
+ def __init__(self, *args, **kwargs):
+ events = kwargs.pop('events')
+ super().__init__(*args, **kwargs)
+ self.fields['events'].queryset = events
diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py
index 1329a1bc8..c0fcc6f29 100644
--- a/src/pretix/control/forms/subevents.py
+++ b/src/pretix/control/forms/subevents.py
@@ -133,16 +133,12 @@ class SubEventBulkEditForm(I18nModelForm):
# i18n fields
if k in self.mixed_values:
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
- else:
- self.fields[k].widget.attrs['placeholder'] = ''
self.fields[k].one_required = False
for k in ('geo_lat', 'geo_lon', 'comment'):
# scalar fields
if k in self.mixed_values:
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
- else:
- self.fields[k].widget.attrs['placeholder'] = ''
self.fields[k].widget.is_required = False
self.fields[k].required = False
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
index 3b7498594..669926afc 100644
--- a/src/pretix/control/forms/vouchers.py
+++ b/src/pretix/control/forms/vouchers.py
@@ -41,7 +41,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
from django.db.models.functions import Upper
from django.urls import reverse
-from django.utils.translation import gettext_lazy as _, pgettext_lazy
+from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders
@@ -115,7 +115,6 @@ class VoucherForm(I18nModelForm):
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
}),
- 'data-placeholder': pgettext_lazy('subevent', 'Date')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
@@ -208,12 +207,15 @@ class VoucherForm(I18nModelForm):
if self.instance and self.instance.pk:
cnt -= self.instance.redeemed # these do not need quota any more
- Voucher.clean_item_properties(
- data, self.instance.event,
- self.instance.quota, self.instance.item, self.instance.variation,
- seats_given=data.get('seat') or data.get('seats'),
- block_quota=data.get('block_quota')
- )
+ try:
+ Voucher.clean_item_properties(
+ data, self.instance.event,
+ self.instance.quota, self.instance.item, self.instance.variation,
+ seats_given=data.get('seat') or data.get('seats'),
+ block_quota=data.get('block_quota')
+ )
+ except ValidationError as e:
+ raise ValidationError({"itemvar": e.message})
if not data.get('show_hidden_items') and (
(self.instance.quota and all(i.hide_without_voucher for i in self.instance.quota.items.all()))
or (self.instance.item and self.instance.item.hide_without_voucher)
@@ -224,10 +226,17 @@ class VoucherForm(I18nModelForm):
'them.')
]
})
- Voucher.clean_subevent(
- data, self.instance.event
- )
- Voucher.clean_max_usages(data, self.instance.redeemed)
+
+ try:
+ Voucher.clean_subevent(
+ data, self.instance.event
+ )
+ except ValidationError as e:
+ raise ValidationError({"subevent": e.message})
+ try:
+ Voucher.clean_max_usages(data, self.instance.redeemed)
+ except ValidationError as e:
+ raise ValidationError({"max_usages": e.message})
check_quota = Voucher.clean_quota_needs_checking(
data, self.initial_instance_data,
item_changed=data.get('itemvar') != self.initial.get('itemvar'),
@@ -294,7 +303,7 @@ class VoucherBulkForm(VoucherForm):
}),
required=False,
help_text=_('You can either supply a list of email addresses with one email address per line, or the contents '
- 'of a CSV file with a title column and one or more of the columns "email", "number", "name", '
+ 'of a CSV file with a title row and one or more of the columns "email", "number", "name", '
'or "tag".')
)
Recipient = namedtuple('Recipient', 'email number name tag')
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 58001cb36..7cf4f1ee5 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -43,9 +43,11 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape, format_html
+from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.strings import LazyI18nString
+from pretix.base.datasync.datasync import datasync_providers
from pretix.base.logentrytypes import (
DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType,
ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType,
@@ -319,6 +321,14 @@ class OrderChangedSplitFrom(OrderLogEntryType):
_('Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'),
_('Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'),
),
+ 'pretix.event.checkin.annulled': (
+ _('Annulled scan of position #{posid} at {datetime} for list "{list}", type "{type}".'),
+ _('Annulled scan of position #{posid} for list "{list}", type "{type}".'),
+ ),
+ 'pretix.event.checkin.annulment.ignored': (
+ _('Ignored annulment of position #{posid} at {datetime} for list "{list}", type "{type}".'),
+ _('Ignored annulment of position #{posid} for list "{list}", type "{type}".'),
+ ),
'pretix.control.views.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
})
@@ -421,6 +431,51 @@ class OrderPrintLogEntryType(OrderLogEntryType):
)
+class OrderDataSyncLogEntryType(OrderLogEntryType):
+ def display(self, logentry, data):
+ try:
+ from pretix.base.datasync.datasync import datasync_providers
+ provider_class, meta = datasync_providers.get(identifier=data['provider'])
+ data['provider_display_name'] = provider_class.display_name
+ except (KeyError, AttributeError):
+ data['provider_display_name'] = data.get('provider')
+ return super().display(logentry, data)
+
+
+@log_entry_types.new_from_dict({
+ "pretix.event.order.data_sync.success": _("Data successfully transferred to {provider_display_name}."),
+})
+class OrderDataSyncSuccessLogEntryType(OrderDataSyncLogEntryType):
+ def display(self, logentry, data):
+ links = []
+ if data.get('provider') and data.get('objects'):
+ prov, meta = datasync_providers.get(identifier=data['provider'])
+ if prov:
+ for objs in data['objects'].values():
+ links.append(", ".join(
+ prov.get_external_link_html(logentry.event, obj['external_link_href'], obj['external_link_display_name'])
+ for obj in objs
+ if obj and 'external_link_href' in obj and 'external_link_display_name' in obj
+ ))
+
+ return mark_safe(escape(super().display(logentry, data)) + "".join("