diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst
index abf2a9b227..caeeae3d1f 100644
--- a/doc/development/api/general.rst
+++ b/doc/development/api/general.rst
@@ -11,7 +11,8 @@ Core
----
.. automodule:: pretix.base.signals
- :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types
+ :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
+ item_copy_data
Order events
""""""""""""
@@ -56,6 +57,12 @@ Backend
Vouchers
""""""""
+.. automodule:: pretix.control.signals
+ :members: item_forms
+
+Vouchers
+""""""""
+
.. automodule:: pretix.control.signals
:members: voucher_form_class, voucher_form_html, voucher_form_validation
diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py
index 8adb1958fc..f0428009bb 100644
--- a/src/pretix/base/models/auth.py
+++ b/src/pretix/base/models/auth.py
@@ -248,6 +248,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
teams = self._get_teams_for_event(organizer, event)
if teams:
self._teamcache['e{}'.format(event.pk)] = teams
+ if isinstance(perm_name, (tuple, list)):
+ return any([any(team.has_permission(p) for team in teams) for p in perm_name])
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
@@ -266,6 +268,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True
teams = self._get_teams_for_organizer(organizer)
if teams:
+ if isinstance(perm_name, (tuple, list)):
+ return any([any(team.has_permission(p) for team in teams) for p in perm_name])
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py
index 8a6e380cd0..a53af517df 100644
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -278,6 +278,8 @@ class TeamAPIToken(models.Model):
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all()
)
+ if isinstance(perm_name, (tuple, list)):
+ return has_event_access and any(self.team.has_permission(p) for p in perm_name)
return has_event_access and (not perm_name or self.team.has_permission(perm_name))
def has_organizer_permission(self, organizer, perm_name=None, request=None):
@@ -290,6 +292,8 @@ class TeamAPIToken(models.Model):
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
+ if isinstance(perm_name, (tuple, list)):
+ return organizer == self.team.organizer and any(self.team.has_permission(p) for p in perm_name)
return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))
def get_events_with_any_permission(self):
diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py
index 190718c92d..71d0829568 100644
--- a/src/pretix/base/pdf.py
+++ b/src/pretix/base/pdf.py
@@ -1,12 +1,36 @@
import copy
+import logging
+import re
+import uuid
from collections import OrderedDict
+from io import BytesIO
+import bleach
+from django.contrib.staticfiles import finders
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
+from PyPDF2 import PdfFileReader
from pytz import timezone
+from reportlab.graphics import renderPDF
+from reportlab.graphics.barcode.qr import QrCodeWidget
+from reportlab.graphics.shapes import Drawing
+from reportlab.lib.colors import Color
+from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.pdfmetrics import getAscentDescent
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import Paragraph
+from pretix.base.models import Order, OrderPosition
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
+from pretix.presale.style import get_fonts
+
+logger = logging.getLogger(__name__)
+
DEFAULT_VARIABLES = OrderedDict((
("secret", {
@@ -157,3 +181,118 @@ def get_variables(event):
for recv, res in layout_text_variables.send(sender=event):
v.update(res)
return v
+
+
+class Renderer:
+
+ def __init__(self, event, layout, background_file):
+ self.layout = layout
+ self.background_file = background_file
+ self.variables = get_variables(event)
+ if self.background_file:
+ self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()))
+ else:
+ self.bg_pdf = None
+
+ @classmethod
+ def _register_fonts(cls):
+ pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
+ pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
+ pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
+ pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
+
+ for family, styles in get_fonts().items():
+ pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
+ 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 _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
+ reqs = float(o['size']) * mm
+ qrw = QrCodeWidget(op.secret, barLevel='H', barHeight=reqs, barWidth=reqs)
+ d = Drawing(reqs, reqs)
+ d.add(qrw)
+ qr_x = float(o['left']) * mm
+ qr_y = float(o['bottom']) * mm
+ renderPDF.draw(d, canvas, qr_x, qr_y)
+
+ def _get_text_content(self, op: OrderPosition, order: Order, o: dict):
+ ev = op.subevent or order.event
+ if not o['content']:
+ return '(error)'
+ if o['content'] == 'other':
+ return o['text'].replace("\n", " \n")
+ elif o['content'].startswith('meta:'):
+ return ev.meta_data.get(o['content'][5:]) or ''
+ elif o['content'] in self.variables:
+ try:
+ return self.variables[o['content']]['evaluate'](op, order, ev)
+ except:
+ logger.exception('Failed to process variable.')
+ return '(error)'
+ return ''
+
+ def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+ font = o['fontfamily']
+ if o['bold']:
+ font += ' B'
+ if o['italic']:
+ font += ' I'
+
+ align_map = {
+ 'left': TA_LEFT,
+ 'center': TA_CENTER,
+ 'right': TA_RIGHT
+ }
+ style = ParagraphStyle(
+ name=uuid.uuid4().hex,
+ fontName=font,
+ fontSize=float(o['fontsize']),
+ leading=float(o['fontsize']),
+ autoLeading="max",
+ textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
+ alignment=align_map[o['align']]
+ )
+ text = re.sub(
+ " ]*>", " ",
+ bleach.clean(
+ self._get_text_content(op, order, o) or "",
+ tags=["br"], attributes={}, styles=[], strip=True
+ )
+ )
+ p = Paragraph(text, style=style)
+ p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
+ # p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
+ ad = getAscentDescent(font, float(o['fontsize']))
+ p.drawOn(canvas, float(o['left']) * mm, float(o['bottom']) * mm - ad[1])
+
+ def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition):
+ for o in self.layout:
+ if o['type'] == "barcodearea":
+ self._draw_barcodearea(canvas, op, o)
+ elif o['type'] == "textarea":
+ self._draw_textarea(canvas, op, order, o)
+ canvas.showPage()
+
+ def render_background(self, buffer, title=_('Ticket')):
+ from PyPDF2 import PdfFileWriter, PdfFileReader
+ buffer.seek(0)
+ new_pdf = PdfFileReader(buffer)
+ output = PdfFileWriter()
+
+ for page in new_pdf.pages:
+ bg_page = copy.copy(self.bg_pdf.getPage(0))
+ bg_page.mergePage(page)
+ output.addPage(bg_page)
+
+ output.addMetadata({
+ '/Title': str(title),
+ '/Creator': 'pretix',
+ })
+ outbuffer = BytesIO()
+ output.write(outbuffer)
+ outbuffer.seek(0)
+ return outbuffer
diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py
index 67aea57afd..bf80ffebe7 100644
--- a/src/pretix/base/signals.py
+++ b/src/pretix/base/signals.py
@@ -258,7 +258,7 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
"""
event_copy_data = EventPluginSignal(
- providing_args=["other"]
+ providing_args=["other", "tax_map", "category_map", "item_map", "question_map", "variation_map"]
)
"""
This signal is sent out when a new event is created as a clone of an existing event, i.e.
@@ -275,6 +275,18 @@ mappings from object IDs in the original event to objects in the new event of th
types.
"""
+item_copy_data = EventPluginSignal(
+ providing_args=["source", "target"]
+)
+"""
+This signal is sent out when a new product is created as a clone of an existing product, i.e.
+the settings from the older product are copied to the newer one. You can listen to this
+signal to copy data or configuration stored within your plugin's models as well.
+
+The ``sender`` keyword argument will contain the event. The ``target`` will contain the item to
+copy to, the ``source`` keyword argument will contain the product to **copy from**.
+"""
+
periodic_task = django.dispatch.Signal()
"""
This is a regular django signal (no pretix event signal) that we send out every
diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py
index 6f90f3cac6..ba4b6354a9 100644
--- a/src/pretix/control/forms/item.py
+++ b/src/pretix/control/forms/item.py
@@ -15,6 +15,7 @@ from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn
+from pretix.base.signals import item_copy_data
from pretix.control.forms import SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
from pretix.helpers.money import change_decimal_field
@@ -255,6 +256,8 @@ class ItemCreateForm(I18nModelForm):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
+ item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
+
return instance
def clean(self):
diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py
index 25b9d8c53f..dfa3c1419f 100644
--- a/src/pretix/control/signals.py
+++ b/src/pretix/control/signals.py
@@ -163,7 +163,6 @@ Deprecated signal, no longer works. We just keep the definition so old plugins d
break the installation.
"""
-
nav_organizer = Signal(
providing_args=['organizer', 'request']
)
@@ -192,7 +191,6 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
Additionally, the argument ``order`` and ``request`` are available.
"""
-
nav_event_settings = EventPluginSignal(
providing_args=['request']
)
@@ -221,3 +219,16 @@ that allows generating a pretix Widget code.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object.
"""
+
+item_forms = EventPluginSignal(
+ providing_args=['request', 'item']
+)
+"""
+This signal allows you to return additional forms that should be rendered on the product
+modification page. You are passed ``request`` and ``item`` arguments and are expected to return
+an instance of a form class that you bind yourself when appropriate. Your form will be executed
+as part of the standard validation and rendering cycle and rendered using default bootstrap
+styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
+
+As with all plugin signals, the ``sender`` keyword argument will contain the event.
+"""
diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html
index 4db4fd933e..538a22c0fe 100644
--- a/src/pretix/control/templates/pretixcontrol/item/index.html
+++ b/src/pretix/control/templates/pretixcontrol/item/index.html
@@ -35,6 +35,14 @@
{% bootstrap_field form.checkin_attention layout="control" %}
+ {% if plugin_forms %}
+