From ce68f52ca00fe73aed471dcbc24e8b41547d1dd8 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 22 Apr 2018 12:02:51 +0200 Subject: [PATCH] Add badge printing capabilities (#868) Add badge printing capabilities --- doc/development/api/general.rst | 9 +- src/pretix/base/models/auth.py | 4 + src/pretix/base/models/organizer.py | 4 + src/pretix/base/pdf.py | 139 +++++++++++ src/pretix/base/signals.py | 14 +- src/pretix/control/forms/item.py | 3 + src/pretix/control/signals.py | 15 +- .../templates/pretixcontrol/item/index.html | 8 + src/pretix/control/views/item.py | 54 ++++- src/pretix/plugins/badges/__init__.py | 28 +++ src/pretix/plugins/badges/exporters.py | 103 ++++++++ src/pretix/plugins/badges/forms.py | 32 +++ .../plugins/badges/migrations/0001_initial.py | 48 ++++ .../plugins/badges/migrations/__init__.py | 0 src/pretix/plugins/badges/models.py | 51 ++++ src/pretix/plugins/badges/signals.py | 129 ++++++++++ .../badges/badge_default_a6l.pdf | Bin 0 -> 874 bytes .../badges/badge_default_a6l.svg | 56 +++++ src/pretix/plugins/badges/tasks.py | 24 ++ .../badges/control_order_info.html | 20 ++ .../pretixplugins/badges/delete.html | 20 ++ .../templates/pretixplugins/badges/edit.html | 39 +++ .../templates/pretixplugins/badges/index.html | 81 +++++++ src/pretix/plugins/badges/urls.py | 21 ++ src/pretix/plugins/badges/views.py | 224 ++++++++++++++++++ .../plugins/ticketoutputpdf/ticketoutput.py | 120 +--------- src/pretix/settings.py | 1 + src/tests/base/test_permissions.py | 4 + src/tests/plugins/badges/__init__.py | 0 src/tests/plugins/badges/test_control.py | 125 ++++++++++ src/tests/plugins/badges/test_pdf.py | 68 ++++++ 31 files changed, 1312 insertions(+), 132 deletions(-) create mode 100644 src/pretix/plugins/badges/__init__.py create mode 100644 src/pretix/plugins/badges/exporters.py create mode 100644 src/pretix/plugins/badges/forms.py create mode 100644 src/pretix/plugins/badges/migrations/0001_initial.py create mode 100644 src/pretix/plugins/badges/migrations/__init__.py create mode 100644 src/pretix/plugins/badges/models.py create mode 100644 src/pretix/plugins/badges/signals.py create mode 100644 src/pretix/plugins/badges/static/pretixplugins/badges/badge_default_a6l.pdf create mode 100644 src/pretix/plugins/badges/static/pretixplugins/badges/badge_default_a6l.svg create mode 100644 src/pretix/plugins/badges/tasks.py create mode 100644 src/pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html create mode 100644 src/pretix/plugins/badges/templates/pretixplugins/badges/delete.html create mode 100644 src/pretix/plugins/badges/templates/pretixplugins/badges/edit.html create mode 100644 src/pretix/plugins/badges/templates/pretixplugins/badges/index.html create mode 100644 src/pretix/plugins/badges/urls.py create mode 100644 src/pretix/plugins/badges/views.py create mode 100644 src/tests/plugins/badges/__init__.py create mode 100644 src/tests/plugins/badges/test_control.py create mode 100644 src/tests/plugins/badges/test_pdf.py 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 @@ {% trans "Check-in" %} {% bootstrap_field form.checkin_attention layout="control" %} + {% if plugin_forms %} +
+ {% trans "Additional settings" %} + {% for f in plugin_forms %} + {% bootstrap_form f layout="control" %} + {% endfor %} +
+ {% endif %}
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 5353f4592a..fe51ae0cda 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -31,6 +31,7 @@ from pretix.control.forms.item import ( from pretix.control.permissions import ( EventPermissionRequiredMixin, event_permission_required, ) +from pretix.control.signals import item_forms from . import ChartContainingView, CreateView, PaginationMixin, UpdateView @@ -332,7 +333,6 @@ class QuestionDelete(EventPermissionRequiredMixin, DeleteView): class QuestionMixin: - @cached_property def formset(self): formsetclass = inlineformset_factory( @@ -420,8 +420,8 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV } ] elif self.object.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE): - qs = qs.order_by('options').values('options', 'options__answer')\ - .annotate(count=Count('id')).order_by('-count') + qs = qs.order_by('options').values('options', 'options__answer') \ + .annotate(count=Count('id')).order_by('-count') for a in qs: a['answer'] = str(a['options__answer']) del a['options__answer'] @@ -684,8 +684,8 @@ class QuotaUpdate(EventPermissionRequiredMixin, UpdateView): k: form.cleaned_data.get(k) for k in form.changed_data } ) - if ((form.initial.get('subevent') and not form.instance.subevent) - or (form.instance.subevent and form.initial.get('subevent') != form.instance.subevent.pk)): + if ((form.initial.get('subevent') and not form.instance.subevent) or + (form.instance.subevent and form.initial.get('subevent') != form.instance.subevent.pk)): if form.initial.get('subevent'): se = SubEvent.objects.get(event=self.request.event, pk=form.initial.get('subevent')) @@ -817,6 +817,13 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie template_name = 'pretixcontrol/item/index.html' permission = 'can_change_items' + @cached_property + def plugin_forms(self): + forms = [] + for rec, resp in item_forms.send(sender=self.request.event, item=self.item, request=self.request): + forms.append(resp) + return forms + def get_success_url(self) -> str: return reverse('control:event.item', kwargs={ 'organizer': self.request.event.organizer.slug, @@ -824,25 +831,48 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie 'item': self.get_object().id, }) + def post(self, request, *args, **kwargs): + self.get_object() + form = self.get_form() + if form.is_valid() and all(f.is_valid() for f in self.plugin_forms): + return self.form_valid(form) + else: + return self.form_invalid(form) + @transaction.atomic def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) - if form.has_changed(): + if form.has_changed() or any(f.has_changed() for f in self.plugin_forms): + data = { + k: (form.cleaned_data.get(k).name + if isinstance(form.cleaned_data.get(k), File) + else form.cleaned_data.get(k)) + for k in form.changed_data + } + for f in self.plugin_forms: + data.update({ + k: (f.cleaned_data.get(k).name + if isinstance(f.cleaned_data.get(k), File) + else f.cleaned_data.get(k)) + for k in f.changed_data + }) self.object.log_action( - 'pretix.event.item.changed', user=self.request.user, data={ - k: (form.cleaned_data.get(k).name - if isinstance(form.cleaned_data.get(k), File) - else form.cleaned_data.get(k)) - for k in form.changed_data - } + 'pretix.event.item.changed', user=self.request.user, data=data ) CachedTicket.objects.filter(order_position__item=self.item).delete() + for f in self.plugin_forms: + f.save() return super().form_valid(form) def form_invalid(self, form): messages.error(self.request, _('We could not save your changes. See below for details.')) return super().form_invalid(form) + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['plugin_forms'] = self.plugin_forms + return ctx + class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): permission = 'can_change_items' diff --git a/src/pretix/plugins/badges/__init__.py b/src/pretix/plugins/badges/__init__.py new file mode 100644 index 0000000000..2f1b9d439f --- /dev/null +++ b/src/pretix/plugins/badges/__init__.py @@ -0,0 +1,28 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext, ugettext_lazy as _ + +from pretix import __version__ as version + + +class BadgesApp(AppConfig): + name = 'pretix.plugins.badges' + verbose_name = _("Badges") + + class PretixPluginMeta: + name = _("Badges") + author = _("the pretix team") + version = version + description = _("This plugin allows you to generate badges or name tags for your attendees.") + + def ready(self): + from . import signals # NOQA + + def installed(self, event): + if not event.badge_layouts.exists(): + event.badge_layouts.create( + name=ugettext('Default'), + default=True, + ) + + +default_app_config = 'pretix.plugins.badges.BadgesApp' diff --git a/src/pretix/plugins/badges/exporters.py b/src/pretix/plugins/badges/exporters.py new file mode 100644 index 0000000000..bb0f08b546 --- /dev/null +++ b/src/pretix/plugins/badges/exporters.py @@ -0,0 +1,103 @@ +import json +from collections import OrderedDict +from io import BytesIO +from typing import Tuple + +from django import forms +from django.contrib.staticfiles import finders +from django.core.files import File +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.utils.translation import ugettext as _ +from PyPDF2 import PdfFileMerger +from reportlab.lib import pagesizes +from reportlab.pdfgen import canvas + +from pretix.base.exporter import BaseExporter +from pretix.base.i18n import language +from pretix.base.models import Order, OrderPosition +from pretix.base.pdf import Renderer +from pretix.plugins.badges.models import BadgeItem, BadgeLayout + + +def _renderer(event, layout): + if isinstance(layout.background, File) and layout.background.name: + bgf = default_storage.open(layout.background.name, "rb") + else: + bgf = open(finders.find('pretixplugins/badges/badge_default_a6l.pdf'), "rb") + return Renderer(event, json.loads(layout.layout), bgf) + + +def render_pdf(event, positions): + Renderer._register_fonts() + + renderermap = { + bi.item_id: _renderer(event, bi.layout) + for bi in BadgeItem.objects.select_related('layout').filter(item__event=event) + } + try: + default_renderer = _renderer(event, event.badge_layouts.get(default=True)) + except BadgeLayout.DoesNotExist: + default_renderer = None + merger = PdfFileMerger() + + for op in positions: + r = renderermap.get(op.item_id, default_renderer) + if not r: + continue + + with language(op.order.locale): + buffer = BytesIO() + p = canvas.Canvas(buffer, pagesize=pagesizes.A4) + r.draw_page(p, op.order, op) + p.save() + outbuffer = r.render_background(buffer, 'Badge') + merger.append(ContentFile(outbuffer.read())) + + outbuffer = BytesIO() + merger.write(outbuffer) + merger.close() + outbuffer.seek(0) + return outbuffer + + +class BadgeExporter(BaseExporter): + identifier = "badges" + verbose_name = _("Attendee badges") + + @property + def export_form_fields(self): + d = OrderedDict( + [ + ('items', + forms.ModelMultipleChoiceField( + queryset=self.event.items.all(), + label=_('Limit to products'), + widget=forms.CheckboxSelectMultiple( + attrs={'class': 'scrolling-multiple-choice'} + ), + initial=self.event.items.filter(admission=True) + )), + ('include_pending', + forms.BooleanField( + label=_('Include pending orders'), + required=False + )), + ] + ) + return d + + def render(self, form_data: dict) -> Tuple[str, str, str]: + qs = OrderPosition.objects.filter( + order__event=self.event, item_id__in=form_data['items'] + ).prefetch_related( + 'answers', 'answers__question' + ).select_related('order', 'item', 'variation', 'addon_to') + + if form_data.get('include_pending'): + qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING]) + else: + qs = qs.filter(order__status__in=[Order.STATUS_PAID]) + + outbuffer = render_pdf(self.event, qs) + return 'badges.pdf', 'application/pdf', outbuffer.read() diff --git a/src/pretix/plugins/badges/forms.py b/src/pretix/plugins/badges/forms.py new file mode 100644 index 0000000000..293912b066 --- /dev/null +++ b/src/pretix/plugins/badges/forms.py @@ -0,0 +1,32 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from pretix.plugins.badges.models import BadgeItem, BadgeLayout + + +class BadgeLayoutForm(forms.ModelForm): + class Meta: + model = BadgeLayout + fields = ('name',) + + +class BadgeItemForm(forms.ModelForm): + class Meta: + model = BadgeItem + fields = ('layout',) + + def __init__(self, *args, **kwargs): + event = kwargs.pop('event') + super().__init__(*args, **kwargs) + self.fields['layout'].label = _('Badge layout') + self.fields['layout'].queryset = event.badge_layouts.all() + self.fields['layout'].required = False + + def save(self, commit=True): + if self.cleaned_data['layout'] is None: + if self.instance.pk: + self.instance.delete() + else: + return + else: + return super().save(commit=commit) diff --git a/src/pretix/plugins/badges/migrations/0001_initial.py b/src/pretix/plugins/badges/migrations/0001_initial.py new file mode 100644 index 0000000000..332356d686 --- /dev/null +++ b/src/pretix/plugins/badges/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-16 08:04 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base +import pretix.plugins.badges.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('pretixbase', '0088_auto_20180328_1217'), + ] + + operations = [ + migrations.CreateModel( + name='BadgeItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='badge_assignment', to='pretixbase.Item')), + ], + ), + migrations.CreateModel( + name='BadgeLayout', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default', models.BooleanField(default=True, verbose_name='Default')), + ('name', models.CharField(max_length=190, verbose_name='Name')), + ('layout', models.TextField()), + ('background', models.FileField(blank=True, max_length=255, null=True, upload_to=pretix.plugins.badges.models.bg_name)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badge_layouts', to='pretixbase.Event')), + ], + options={ + 'ordering': ('-default', 'name'), + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AddField( + model_name='badgeitem', + name='layout', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='badges.BadgeLayout'), + ), + ] diff --git a/src/pretix/plugins/badges/migrations/__init__.py b/src/pretix/plugins/badges/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/plugins/badges/models.py b/src/pretix/plugins/badges/models.py new file mode 100644 index 0000000000..384d2842bb --- /dev/null +++ b/src/pretix/plugins/badges/models.py @@ -0,0 +1,51 @@ +import string + +from django.db import models +from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import LoggedModel + + +def bg_name(instance, filename: str) -> str: + secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits) + return 'pub/{org}/{ev}/badges/{id}-{secret}.pdf'.format( + org=instance.event.organizer.slug, + ev=instance.event.slug, + id=instance.pk, + secret=secret + ) + + +class BadgeLayout(LoggedModel): + event = models.ForeignKey( + 'pretixbase.Event', + on_delete=models.CASCADE, + related_name='badge_layouts' + ) + default = models.BooleanField( + verbose_name=_('Default'), + default=False, + ) + name = models.CharField( + max_length=190, + verbose_name=_('Name') + ) + layout = models.TextField( + default='[{"type":"textarea","left":"13.09","bottom":"49.73","fontsize":"23.6","color":[0,0,0,1],' + '"fontfamily":"Open Sans","bold":true,"italic":false,"width":"121.83","content":"attendee_name",' + '"text":"Max Mustermann","align":"center"}]' + ) + background = models.FileField(null=True, blank=True, upload_to=bg_name, max_length=255) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + +class BadgeItem(models.Model): + item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='badge_assignment', + on_delete=models.CASCADE) + layout = models.ForeignKey('BadgeLayout', on_delete=models.CASCADE, related_name='item_assignments') diff --git a/src/pretix/plugins/badges/signals.py b/src/pretix/plugins/badges/signals.py new file mode 100644 index 0000000000..80eb0ad4b6 --- /dev/null +++ b/src/pretix/plugins/badges/signals.py @@ -0,0 +1,129 @@ +import copy + +from django.dispatch import receiver +from django.template.loader import get_template +from django.urls import resolve, reverse +from django.utils.html import escape +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import Event, Order +from pretix.base.signals import ( + event_copy_data, item_copy_data, logentry_display, logentry_object_link, + register_data_exporters, +) +from pretix.control.signals import item_forms, nav_event, order_info +from pretix.plugins.badges.forms import BadgeItemForm +from pretix.plugins.badges.models import BadgeItem, BadgeLayout + + +@receiver(nav_event, dispatch_uid="badges_nav") +def control_nav_import(sender, request=None, **kwargs): + url = resolve(request.path_info) + p = ( + request.user.has_event_permission(request.organizer, request.event, 'can_change_settings') + or request.user.has_event_permission(request.organizer, request.event, 'can_view_orders') + ) + if not p: + return [] + return [ + { + 'label': _('Badges'), + 'url': reverse('plugins:badges:index', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.namespace == 'plugins:badges', + 'icon': 'id-card', + } + ] + + +@receiver(item_forms, dispatch_uid="badges_item_forms") +def control_item_forms(sender, request, item, **kwargs): + try: + inst = BadgeItem.objects.get(item=item) + except BadgeItem.DoesNotExist: + inst = BadgeItem(item=item) + return BadgeItemForm( + instance=inst, + event=sender, + data=(request.POST if request.method == "POST" else None), + prefix="badgeitem" + ) + + +@receiver(item_copy_data, dispatch_uid="badges_item_copy") +def copy_item(sender, source, target, **kwargs): + try: + inst = BadgeItem.objects.get(item=source) + BadgeItem.objects.create(item=target, layout=inst.layout) + except BadgeItem.DoesNotExist: + pass + + +@receiver(signal=event_copy_data, dispatch_uid="badges_copy_data") +def event_copy_data_receiver(sender, other, item_map, **kwargs): + layout_map = {} + for bl in other.badge_layouts.all(): + oldid = bl.pk + bl = copy.copy(bl) + bl.pk = None + bl.event = sender + bl.save() + layout_map[oldid] = bl + + for bi in BadgeItem.objects.filter(item__event=other): + BadgeItem.objects.create(item=item_map.get(bi.item_id), layout=layout_map.get(bi.layout_id)) + + +@receiver(register_data_exporters, dispatch_uid="badges_export_all") +def register_pdf(sender, **kwargs): + from .exporters import BadgeExporter + return BadgeExporter + + +@receiver(order_info, dispatch_uid="badges_control_order_info") +def control_order_info(sender: Event, request, order: Order, **kwargs): + + template = get_template('pretixplugins/badges/control_order_info.html') + + ctx = { + 'order': order, + 'request': request, + 'event': sender, + } + return template.render(ctx, request=request) + + +@receiver(signal=logentry_display, dispatch_uid="badges_logentry_display") +def badges_logentry_display(sender, logentry, **kwargs): + if not logentry.action_type.startswith('pretix.plugins.badges'): + return + + plains = { + 'pretix.plugins.badges.layout.added': _('Badge layout created.'), + 'pretix.plugins.badges.layout.deleted': _('Badge layout deleted.'), + 'pretix.plugins.badges.layout.changed': _('Badge layout changed.'), + } + + if logentry.action_type in plains: + return plains[logentry.action_type] + + +@receiver(signal=logentry_object_link, dispatch_uid="badges_logentry_object_link") +def badges_logentry_object_link(sender, logentry, **kwargs): + if not logentry.action_type.startswith('pretix.plugins.badges.layout') or not isinstance(logentry.content_object, + BadgeLayout): + return + + a_text = _('Badge layout {val}') + a_map = { + 'href': reverse('plugins:badges:edit', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug, + 'layout': logentry.content_object.id + }), + 'val': escape(logentry.content_object.name), + } + a_map['val'] = '{val}'.format_map(a_map) + return a_text.format_map(a_map) diff --git a/src/pretix/plugins/badges/static/pretixplugins/badges/badge_default_a6l.pdf b/src/pretix/plugins/badges/static/pretixplugins/badges/badge_default_a6l.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1a7d9e8ce55982afbe041c478567716720b1cba5 GIT binary patch literal 874 zcmZXTv2NQi5Qej4;okigp*D(2q$Em8U}Ugd2SHQBP|~T$z)G~NLKO{)E}T9^A0Tr^ zZ+(XVeU?5!my)!l2o4vDACGtU^QWA}Z2q3O6gj{D?*5?=Fo>_;&}0JMHE&9_0;*Al z0Kl79wc=9GHB)@Xb5U?~ae-PTIs1;bkHH;2nck!xA5Uypx2KfifCJ-JCv zpAc_~L$LlbWlU}}>+C^jw#_2wdobA#(C;C5cMoe0-h!1J93Zx}1(Uo{K>A)k@uFf= zv4t-hVF<~YOZ}MA82q!?jeTQFj?fli~KGvGyMYfpWwidO3} zSCRF-Z;b3gf@qLnAw{vtwPmB6RZ;+P33Um9(MqZHx#wA0O155AdFu*Uj>oo8str|@ zXlB}H7|qUoOcETD$j3OL + + + + + + + + + image/svg+xml + + + + + + + diff --git a/src/pretix/plugins/badges/tasks.py b/src/pretix/plugins/badges/tasks.py new file mode 100644 index 0000000000..f07c8f211a --- /dev/null +++ b/src/pretix/plugins/badges/tasks.py @@ -0,0 +1,24 @@ +import logging +from typing import List + +from django.core.files.base import ContentFile + +from pretix.base.models import ( + CachedFile, Event, OrderPosition, cachedfile_name, +) +from pretix.celery_app import app + +from .exporters import render_pdf + +logger = logging.getLogger(__name__) + + +@app.task() +def badges_create_pdf(fileid: int, event: int, orders: List[int]) -> int: + file = CachedFile.objects.get(id=fileid) + event = Event.objects.get(id=event) + + pdfcontent = render_pdf(event, OrderPosition.objects.filter(order_id__in=orders)) + file.file.save(cachedfile_name(file, file.filename), ContentFile(pdfcontent.read())) + file.save() + return file.pk diff --git a/src/pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html b/src/pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html new file mode 100644 index 0000000000..6f6513cd52 --- /dev/null +++ b/src/pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html @@ -0,0 +1,20 @@ +{% load i18n %} +{% load eventurl %} +{% load bootstrap3 %} +
+
+

+ {% trans "Badges" %} +

+
+
+
+ {% csrf_token %} +   +
+
+
diff --git a/src/pretix/plugins/badges/templates/pretixplugins/badges/delete.html b/src/pretix/plugins/badges/templates/pretixplugins/badges/delete.html new file mode 100644 index 0000000000..deaf25336a --- /dev/null +++ b/src/pretix/plugins/badges/templates/pretixplugins/badges/delete.html @@ -0,0 +1,20 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Badges" %}{% endblock %} +{% block content %} +

{% trans "Badges" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to delete the badge layout {{ layout }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/plugins/badges/templates/pretixplugins/badges/edit.html b/src/pretix/plugins/badges/templates/pretixplugins/badges/edit.html new file mode 100644 index 0000000000..125a36e21a --- /dev/null +++ b/src/pretix/plugins/badges/templates/pretixplugins/badges/edit.html @@ -0,0 +1,39 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% if layout %} + {% blocktrans with name=layout.name %}Badge layout: {{ name }}{% endblocktrans %} + {% else %} + {% trans "Badge layout" %} + {% endif %} +{% endblock %} +{% block content %} + {% if layout %} +

{% blocktrans with name=layout.name %}Badge layout: {{ name }}{% endblocktrans %}

+ {% else %} +

{% trans "Badge layout" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.name layout="control" %} +
+ +
+

+ {% blocktrans trimmed %} + You can modify the design after you saved this page. + {% endblocktrans %} +

+
+
+
+ +
+
+{% endblock %} diff --git a/src/pretix/plugins/badges/templates/pretixplugins/badges/index.html b/src/pretix/plugins/badges/templates/pretixplugins/badges/index.html new file mode 100644 index 0000000000..f4d674b51b --- /dev/null +++ b/src/pretix/plugins/badges/templates/pretixplugins/badges/index.html @@ -0,0 +1,81 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load money %} +{% block title %}{% trans "Badges" %}{% endblock %} +{% block content %} +

{% trans "Badges" %}

+ {% if layouts|length == 0 %} +
+

+ {% blocktrans trimmed %} + You haven't created any badge layouts yet. + {% endblocktrans %} +

+ + {% if "can_change_event_settings" in request.eventpermset %} + {% trans "Create a new badge layout" %} + + {% endif %} +
+ {% else %} +

+ {% if "can_change_event_settings" in request.eventpermset %} + {% trans "Create a new badge layout" %} + + {% endif %} + {% trans "Print badges" %} + +

+
+ + + + + + + + + + {% for l in layouts %} + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Default" %}
+ {% if "can_change_event_settings" in request.eventpermset %} + + {{ l.name }} + + {% else %} + {{ l.name }} + {% endif %} + + {% if l.default %} + + + {% trans "Default" %} + + {% elif "can_change_event_settings" in request.eventpermset %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% if "can_change_event_settings" in request.eventpermset %} + + + {% endif %} +
+
+ {% endif %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/plugins/badges/urls.py b/src/pretix/plugins/badges/urls.py new file mode 100644 index 0000000000..1459a18bf8 --- /dev/null +++ b/src/pretix/plugins/badges/urls.py @@ -0,0 +1,21 @@ +from django.conf.urls import url + +from .views import ( + LayoutCreate, LayoutDelete, LayoutEditorView, LayoutListView, + LayoutSetDefault, OrderPrintDo, +) + +urlpatterns = [ + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/$', + LayoutListView.as_view(), name='index'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/print$', + OrderPrintDo.as_view(), name='print'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/add$', + LayoutCreate.as_view(), name='add'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/(?P\d+)/default$', + LayoutSetDefault.as_view(), name='default'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/(?P\d+)/delete$', + LayoutDelete.as_view(), name='delete'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/badges/(?P\d+)/editor', + LayoutEditorView.as_view(), name='edit'), +] diff --git a/src/pretix/plugins/badges/views.py b/src/pretix/plugins/badges/views.py new file mode 100644 index 0000000000..1dca9347bb --- /dev/null +++ b/src/pretix/plugins/badges/views.py @@ -0,0 +1,224 @@ +import json +from datetime import timedelta +from io import BytesIO + +from django.contrib import messages +from django.contrib.staticfiles import finders +from django.core.files import File +from django.core.files.storage import default_storage +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.templatetags.static import static +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from django.views import View +from django.views.generic import CreateView, DeleteView, DetailView, ListView +from reportlab.lib import pagesizes +from reportlab.pdfgen import canvas + +from pretix.base.models import CachedFile, OrderPosition +from pretix.base.pdf import Renderer +from pretix.base.views.async import AsyncAction +from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.views.pdf import BaseEditorView +from pretix.plugins.badges.forms import BadgeLayoutForm +from pretix.plugins.badges.tasks import badges_create_pdf + +from .models import BadgeLayout + + +class LayoutListView(EventPermissionRequiredMixin, ListView): + model = BadgeLayout + permission = ('can_change_event_settings', 'can_view_orders') + template_name = 'pretixplugins/badges/index.html' + context_object_name = 'layouts' + + def get_queryset(self): + return self.request.event.badge_layouts.prefetch_related('item_assignments') + + +class LayoutCreate(EventPermissionRequiredMixin, CreateView): + model = BadgeLayout + form_class = BadgeLayoutForm + template_name = 'pretixplugins/badges/edit.html' + permission = 'can_change_event_settings' + context_object_name = 'layout' + success_url = '/ignored' + + @transaction.atomic + def form_valid(self, form): + form.instance.event = self.request.event + if not self.request.event.badge_layouts.filter(default=True).exists(): + form.instance.default = True + messages.success(self.request, _('The new badge layout has been created.')) + super().form_valid(form) + form.instance.log_action('pretix.plugins.badges.layout.added', user=self.request.user, + data=dict(form.cleaned_data)) + return redirect(reverse('plugins:badges:edit', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'layout': form.instance.pk + })) + + def form_invalid(self, form): + messages.error(self.request, _('We could not save your changes. See below for details.')) + return super().form_invalid(form) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) + + +class LayoutSetDefault(EventPermissionRequiredMixin, DetailView): + model = BadgeLayout + permission = 'can_change_event_settings' + + def get_object(self, queryset=None) -> BadgeLayout: + try: + return self.request.event.badge_layouts.get( + id=self.kwargs['layout'] + ) + except BadgeLayout.DoesNotExist: + raise Http404(_("The requested badge layout does not exist.")) + + @transaction.atomic + def post(self, request, *args, **kwargs): + messages.success(self.request, _('Your changes have been saved.')) + obj = self.get_object() + self.request.event.badge_layouts.exclude(pk=obj.pk).update(default=False) + obj.default = True + obj.save(update_fields=['default']) + return redirect(self.get_success_url()) + + def get_success_url(self) -> str: + return reverse('plugins:badges:index', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + +class LayoutDelete(EventPermissionRequiredMixin, DeleteView): + model = BadgeLayout + template_name = 'pretixplugins/badges/delete.html' + permission = 'can_change_event_settings' + context_object_name = 'layout' + + def get_object(self, queryset=None) -> BadgeLayout: + try: + return self.request.event.badge_layouts.get( + id=self.kwargs['layout'] + ) + except BadgeLayout.DoesNotExist: + raise Http404(_("The requested badge layout does not exist.")) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + self.object.log_action(action='pretix.plugins.badges.layout.deleted', user=request.user) + self.object.delete() + if not self.request.event.badge_layouts.filter(default=True).exists(): + f = self.request.event.badge_layouts.first() + if f: + f.default = True + f.save(update_fields=['default']) + messages.success(self.request, _('The selected badge layout been deleted.')) + return redirect(self.get_success_url()) + + def get_success_url(self) -> str: + return reverse('plugins:badges:index', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + +class LayoutEditorView(BaseEditorView): + @cached_property + def layout(self): + try: + return self.request.event.badge_layouts.get( + id=self.kwargs['layout'] + ) + except BadgeLayout.DoesNotExist: + raise Http404(_("The requested badge layout does not exist.")) + + @property + def title(self): + return _('Badge layout: {}').format(self.layout) + + def save_layout(self): + self.layout.layout = self.request.POST.get("data") + self.layout.save(update_fields=['layout']) + self.layout.log_action(action='pretix.plugins.badges.layout.changed', user=self.request.user, + data={'layout': self.request.POST.get("data")}) + + def get_default_background(self): + return static('pretixplugins/badges/badge_default_a6l.pdf') + + def generate(self, op: OrderPosition, override_layout=None, override_background=None): + Renderer._register_fonts() + + buffer = BytesIO() + if override_background: + bgf = default_storage.open(override_background.name, "rb") + elif isinstance(self.layout.background, File) and self.layout.background.name: + bgf = default_storage.open(self.layout.background.name, "rb") + else: + bgf = open(finders.find('pretixplugins/badges/badge_default_a6l.pdf'), "rb") + r = Renderer( + self.request.event, + override_layout or self.get_current_layout(), + bgf, + ) + p = canvas.Canvas(buffer, pagesize=pagesizes.A4) + r.draw_page(p, op.order, op) + p.save() + outbuffer = r.render_background(buffer, 'Badge') + return 'badge.pdf', 'application/pdf', outbuffer.read() + + def get_current_layout(self): + return json.loads(self.layout.layout) + + def get_current_background(self): + return self.layout.background.url if self.layout.background else self.get_default_background() + + def save_background(self, f: CachedFile): + if self.layout.background: + self.layout.background.delete() + self.layout.background.save('background.pdf', f.file) + + +class OrderPrintDo(EventPermissionRequiredMixin, AsyncAction, View): + task = badges_create_pdf + permission = 'can_view_orders' + + def get_success_message(self, value): + return None + + def get_success_url(self, value): + return reverse('cachedfile.download', kwargs={'id': str(value)}) + + def get_error_url(self): + return reverse('control:event.index', kwargs={ + 'organizer': self.request.organizer.slug, + 'event': self.request.event.slug, + }) + + def get_error_message(self, exception): + if isinstance(exception, str): + return exception + return super().get_error_message(exception) + + def post(self, request, *args, **kwargs): + order = get_object_or_404(self.request.event.orders, code=request.GET.get("code")) + cf = CachedFile() + cf.date = now() + cf.type = 'application/pdf' + cf.expires = now() + timedelta(days=3) + cf.save() + return self.do( + str(cf.id), + self.request.event.pk, + [order.pk], + ) diff --git a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py index d24be8289f..8817856c05 100644 --- a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py +++ b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py @@ -1,34 +1,18 @@ -import copy import logging -import re -import uuid from io import BytesIO -import bleach from django.contrib.staticfiles import finders from django.core.files import File from django.core.files.storage import default_storage from django.http import HttpRequest from django.template.loader import get_template from django.utils.translation import ugettext_lazy as _ -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.i18n import language from pretix.base.models import Order, OrderPosition -from pretix.base.pdf import get_variables +from pretix.base.pdf import Renderer from pretix.base.ticketoutput import BaseTicketOutput -from pretix.plugins.ticketoutputpdf.signals import get_fonts logger = logging.getLogger('pretix.plugins.ticketoutputpdf') @@ -41,92 +25,14 @@ class PdfTicketOutput(BaseTicketOutput): def __init__(self, event, override_layout=None, override_background=None): self.override_layout = override_layout self.override_background = override_background - self.variables = get_variables(event) super().__init__(event) def _register_fonts(self): - 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]) + Renderer._register_fonts() def _draw_page(self, canvas: Canvas, op: OrderPosition, order: Order): objs = self.override_layout or self.settings.get('layout', as_type=list) or self._legacy_layout() - for o in objs: - if o['type'] == "barcodearea": - self._draw_barcodearea(canvas, op, o) - elif o['type'] == "textarea": - self._draw_textarea(canvas, op, order, o) - - canvas.showPage() + Renderer(self.event, objs, None).draw_page(canvas, order, op) def generate_order(self, order: Order): buffer = BytesIO() @@ -166,10 +72,6 @@ class PdfTicketOutput(BaseTicketOutput): return open(finders.find('pretixpresale/pdf/ticket_default_a4.pdf'), "rb") def _render_with_background(self, buffer, title=_('Ticket')): - from PyPDF2 import PdfFileWriter, PdfFileReader - buffer.seek(0) - new_pdf = PdfFileReader(buffer) - output = PdfFileWriter() bg_file = self.settings.get('background', as_type=File) if self.override_background: bgf = default_storage.open(self.override_background.name, "rb") @@ -177,21 +79,7 @@ class PdfTicketOutput(BaseTicketOutput): bgf = default_storage.open(bg_file.name, "rb") else: bgf = self._get_default_background() - bg_pdf = PdfFileReader(BytesIO(bgf.read())) - - for page in new_pdf.pages: - bg_page = copy.copy(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 + return Renderer(self.event, None, bgf).render_background(buffer, title) def settings_content_render(self, request: HttpRequest) -> str: """ diff --git a/src/pretix/settings.py b/src/pretix/settings.py index c0b8082013..4604eff9a3 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -241,6 +241,7 @@ INSTALLED_APPS = [ 'pretix.plugins.reports', 'pretix.plugins.checkinlists', 'pretix.plugins.pretixdroid', + 'pretix.plugins.badges', 'django_markup', 'django_otp', 'django_otp.plugins.otp_totp', diff --git a/src/tests/base/test_permissions.py b/src/tests/base/test_permissions.py index f7a8acefce..c9d1e1b270 100644 --- a/src/tests/base/test_permissions.py +++ b/src/tests/base/test_permissions.py @@ -108,6 +108,9 @@ def test_specific_event_permission_limited(event, user): assert user.has_event_permission(event.organizer, event, 'can_change_orders') assert not user.has_event_permission(event.organizer, event, 'can_change_event_settings') + assert user.has_event_permission(event.organizer, event, ('can_change_orders', 'can_change_event_settings')) + assert not user.has_event_permission(event.organizer, event, ('can_change_teams', 'can_change_event_settings')) + team.can_change_orders = False team.save() user._teamcache = {} @@ -187,6 +190,7 @@ def test_specific_organizer_permission(event, user): team.members.add(user) user._teamcache = {} assert user.has_organizer_permission(event.organizer, 'can_create_events') + assert user.has_organizer_permission(event.organizer, ('can_create_events', 'can_change_organizer_settings')) @pytest.mark.django_db diff --git a/src/tests/plugins/badges/__init__.py b/src/tests/plugins/badges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/tests/plugins/badges/test_control.py b/src/tests/plugins/badges/test_control.py new file mode 100644 index 0000000000..5624c38188 --- /dev/null +++ b/src/tests/plugins/badges/test_control.py @@ -0,0 +1,125 @@ +import datetime + +from tests.base import SoupTest, extract_form_fields + +from pretix.base.models import Event, Item, Organizer, Team, User +from pretix.plugins.badges.models import BadgeItem + + +class BadgeLayoutFormTest(SoupTest): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.orga1 = Organizer.objects.create(name='CCC', slug='ccc') + self.orga2 = Organizer.objects.create(name='MRM', slug='mrm') + self.event1 = Event.objects.create( + organizer=self.orga1, name='30C3', slug='30c3', + plugins='pretix.plugins.badges', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + ) + self.item1 = Item.objects.create(event=self.event1, name="Standard", default_price=0, position=1) + t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_view_orders=True, + can_change_items=True, all_events=True, can_create_events=True) + t.members.add(self.user) + t.limit_events.add(self.event1) + self.client.login(email='dummy@dummy.dummy', password='dummy') + + def test_create(self): + doc = self.get_doc('/control/event/%s/%s/badges/add' % (self.orga1.slug, self.event1.slug)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + form_data['name'] = 'Layout 1' + doc = self.post_doc('/control/event/%s/%s/badges/add' % (self.orga1.slug, self.event1.slug), form_data) + assert doc.select(".alert-success") + self.assertIn("Layout 1", doc.select("#page-wrapper")[0].text) + assert self.event1.badge_layouts.get( + default=True, name='Layout 1' + ) + + def test_set_default(self): + bl1 = self.event1.badge_layouts.create(name="Layout 1", default=True) + bl2 = self.event1.badge_layouts.create(name="Layout 2") + self.post_doc('/control/event/%s/%s/badges/%s/default' % (self.orga1.slug, self.event1.slug, bl2.id), {}) + bl1.refresh_from_db() + assert not bl1.default + bl2.refresh_from_db() + assert bl2.default + + def test_delete(self): + bl1 = self.event1.badge_layouts.create(name="Layout 1", default=True) + bl2 = self.event1.badge_layouts.create(name="Layout 2") + doc = self.get_doc('/control/event/%s/%s/badges/%s/delete' % (self.orga1.slug, self.event1.slug, bl1.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/badges/%s/delete' % (self.orga1.slug, self.event1.slug, bl1.id), + form_data) + assert doc.select(".alert-success") + self.assertNotIn("Layout 1", doc.select("#page-wrapper")[0].text) + assert self.event1.badge_layouts.count() == 1 + bl2.refresh_from_db() + assert bl2.default + + def test_set_on_item(self): + self.event1.badge_layouts.create(name="Layout 1", default=True) + bl2 = self.event1.badge_layouts.create(name="Layout 2") + self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), { + 'name_0': 'Standard', + 'default_price': '23.00', + 'tax_rate': '19.00', + 'active': 'yes', + 'allow_cancel': 'yes', + 'badgeitem-layout': bl2.pk + }) + assert BadgeItem.objects.get(item=self.item1, layout=bl2) + self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), { + 'name_0': 'Standard', + 'default_price': '23.00', + 'tax_rate': '19.00', + 'active': 'yes', + 'allow_cancel': 'yes', + }) + assert not BadgeItem.objects.filter(item=self.item1, layout=bl2).exists() + + def test_item_copy(self): + bl2 = self.event1.badge_layouts.create(name="Layout 2") + BadgeItem.objects.create(item=self.item1, layout=bl2) + self.client.post('/control/event/%s/%s/items/add' % (self.orga1.slug, self.event1.slug), { + 'name_0': 'Intermediate', + 'default_price': '23.00', + 'tax_rate': '19.00', + 'copy_from': str(self.item1.pk), + 'has_variations': '1' + }) + i_new = Item.objects.get(name__icontains='Intermediate') + assert BadgeItem.objects.get(item=i_new, layout=bl2) + assert BadgeItem.objects.get(item=self.item1, layout=bl2) + + def test_copy_event(self): + bl2 = self.event1.badge_layouts.create(name="Layout 2") + BadgeItem.objects.create(item=self.item1, layout=bl2) + self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'foundation', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en',) + }) + self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'basics', + 'basics-name_0': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-timezone': 'Europe/Berlin', + }) + self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'copy', + 'copy-copy_from_event': self.event1.pk + }) + + ev = Event.objects.get(slug='33c3') + i_new = ev.items.first() + bl_new = ev.badge_layouts.first() + assert BadgeItem.objects.get(item=i_new, layout=bl_new) diff --git a/src/tests/plugins/badges/test_pdf.py b/src/tests/plugins/badges/test_pdf.py new file mode 100644 index 0000000000..189a05ce9b --- /dev/null +++ b/src/tests/plugins/badges/test_pdf.py @@ -0,0 +1,68 @@ +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import pytest +from django.utils.timezone import now +from PyPDF2 import PdfFileReader + +from pretix.base.models import ( + Event, Item, ItemVariation, Order, OrderPosition, Organizer, +) +from pretix.plugins.badges.exporters import BadgeExporter + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), live=True + ) + o1 = Order.objects.create( + code='FOOBAR', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('13.37'), payment_provider='banktransfer' + ) + shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12) + shirt_red = ItemVariation.objects.create(item=shirt, default_price=14, value="Red") + OrderPosition.objects.create( + order=o1, item=shirt, variation=shirt_red, + price=12, attendee_name=None, secret='1234' + ) + OrderPosition.objects.create( + order=o1, item=shirt, variation=shirt_red, + price=12, attendee_name=None, secret='5678' + ) + return event, o1, shirt + + +@pytest.mark.django_db +def test_generate_pdf(env): + event, order, shirt = env + event.badge_layouts.create(name="Default", default=True) + e = BadgeExporter(event) + fname, ftype, buf = e.render({ + 'items': [shirt.pk], + 'include_pending': False + }) + assert ftype == 'application/pdf' + pdf = PdfFileReader(BytesIO(buf)) + assert pdf.numPages == 0 + + fname, ftype, buf = e.render({ + 'items': [], + 'include_pending': True + }) + assert ftype == 'application/pdf' + pdf = PdfFileReader(BytesIO(buf)) + assert pdf.numPages == 0 + + fname, ftype, buf = e.render({ + 'items': [shirt.pk], + 'include_pending': True + }) + assert ftype == 'application/pdf' + pdf = PdfFileReader(BytesIO(buf)) + assert pdf.numPages == 2