PDF layout: Allow to show photos from questions (#1919)

This commit is contained in:
Raphael Michel
2021-02-08 17:48:06 +01:00
committed by GitHub
parent 40c4872459
commit 81f37d9ce5
13 changed files with 366 additions and 31 deletions
+5 -1
View File
@@ -220,7 +220,7 @@ downloads list of objects List of ticket
└ url string Download URL └ url string Download URL
answers list of objects Answers to user-defined questions answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question ├ question integer Internal ID of the answered question
├ answer string Text representation of the answer ├ answer string Text representation of the answer (URL if answer is a file)
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
@@ -274,6 +274,10 @@ pdf_data object Data object req
The ``checkin.type`` attribute has been added. The ``checkin.type`` attribute has been added.
.. versionchanged:: 3.16
Answers to file questions are now returned as an URL.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
+1 -1
View File
@@ -79,7 +79,7 @@ Ticket designs
"""""""""""""" """"""""""""""
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: layout_text_variables :members: layout_text_variables, layout_image_variables
.. automodule:: pretix.plugins.ticketoutputpdf.signals .. automodule:: pretix.plugins.ticketoutputpdf.signals
:members: override_layout :members: override_layout
+3
View File
@@ -41,6 +41,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinlistpos-redeem'), ('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:order-list'), ('GET', 'api-v1:order-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
) )
@@ -68,6 +69,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-status'), ('GET', 'api-v1:checkinlist-status'),
('POST', 'api-v1:checkinlistpos-redeem'), ('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
) )
@@ -97,6 +99,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:order-list'), ('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'), ('GET', 'api-v1:order-detail'),
('DELETE', 'api-v1:orderposition-detail'), ('DELETE', 'api-v1:orderposition-detail'),
('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:order-mark_canceled'), ('POST', 'api-v1:order-mark_canceled'),
('POST', 'api-v1:orderpayment-list'), ('POST', 'api-v1:orderpayment-list'),
('POST', 'api-v1:orderrefund-list'), ('POST', 'api-v1:orderrefund-list'),
+37 -2
View File
@@ -25,7 +25,7 @@ from pretix.base.models import (
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
) )
from pretix.base.pdf import get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
@@ -112,6 +112,17 @@ class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
def to_representation(self, instance):
r = super().to_representation(instance)
if r['answer'].startswith('file://') and instance.orderposition:
r['answer'] = reverse('api-v1:orderposition-answer', kwargs={
'organizer': instance.orderposition.order.event.organizer.slug,
'event': instance.orderposition.order.event.slug,
'pk': instance.orderposition.pk,
'question': instance.question_id,
}, request=self.context['request'])
return r
class Meta: class Meta:
model = QuestionAnswer model = QuestionAnswer
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers') fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
@@ -265,6 +276,9 @@ class PdfDataSerializer(serializers.Field):
if 'vars' not in self.context: if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event) self.context['vars'] = get_variables(self.context['request'].event)
if 'vars_images' not in self.context:
self.context['vars_images'] = get_images(self.context['request'].event)
for k, f in self.context['vars'].items(): for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev) res[k] = f['evaluate'](instance, instance.order, ev)
@@ -279,7 +293,28 @@ class PdfDataSerializer(serializers.Field):
for k, v in instance.item._cached_meta_data.items(): for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v res['itemmeta:' + k] = v
return res res['images'] = {}
for k, f in self.context['vars_images'].items():
if 'etag' in f:
has_image = etag = f['etag'](instance, instance.order, ev)
else:
has_image = f['etag'](instance, instance.order, ev)
etag = None
if has_image:
url = reverse('api-v1:orderposition-pdf_image', kwargs={
'organizer': instance.order.event.organizer.slug,
'event': instance.order.event.slug,
'pk': instance.pk,
'key': k,
}, request=self.context['request'])
if etag:
url += f'#etag={etag}'
res['images'][k] = url
else:
res['images'][k] = None
return res
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
+61 -1
View File
@@ -1,4 +1,6 @@
import datetime import datetime
import mimetypes
import os
from decimal import Decimal from decimal import Decimal
import django_filters import django_filters
@@ -12,6 +14,7 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from PIL import Image
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
@@ -35,8 +38,9 @@ from pretix.base.models import (
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TaxRule, TeamAPIToken, generate_secret, TaxRule, TeamAPIToken, generate_secret,
) )
from pretix.base.models.orders import RevokedTicketSecret from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images
from pretix.base.secrets import assign_ticket_secret from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets from pretix.base.services import tickets
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
@@ -913,6 +917,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
'tax_rule': tr.pk if tr else None, 'tax_rule': tr.pk if tr else None,
}) })
@action(detail=True, url_name='answer', url_path=r'answer/(?P<question>\d+)')
def answer(self, request, **kwargs):
pos = self.get_object()
answer = get_object_or_404(
QuestionAnswer,
orderposition=self.get_object(),
question_id=kwargs.get('question')
)
if not answer.file:
raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
)
return resp
@action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P<key>[^/]+)')
def pdf_image(self, request, key, **kwargs):
pos = self.get_object()
image_vars = get_images(request.event)
if key not in image_vars:
raise NotFound('Unknown key')
image_file = image_vars[key]['evaluate'](pos, pos.order, pos.subevent or self.request.event)
if image_file is None:
raise NotFound('No image available')
if getattr(image_file, 'name', ''):
ftype, ignored = mimetypes.guess_type(image_file.name)
extension = os.path.basename(image_file.name).split('.')[-1]
else:
img = Image.open(image_file)
ftype = Image.MIME[img.format]
extensions = {
'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png'
}
extension = extensions.get(img.format, 'bin')
if hasattr(image_file, 'seek'):
image_file.seek(0)
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): def download(self, request, output, **kwargs):
provider = self._get_output_provider(output) provider = self._get_output_provider(output)
+1 -22
View File
@@ -12,12 +12,10 @@ from django.utils.formats import date_format, localize
from django.utils.translation import ( from django.utils.translation import (
get_language, gettext, gettext_lazy, pgettext, get_language, gettext, gettext_lazy, pgettext,
) )
from PIL.Image import BICUBIC
from reportlab.lib import pagesizes from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT, TA_RIGHT from reportlab.lib.enums import TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
@@ -31,6 +29,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Invoice, Order from pretix.base.models import Event, Invoice, Order
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ThumbnailingImageReader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -221,26 +220,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read() return 'invoice.pdf', 'application/pdf', buffer.read()
class ThumbnailingImageReader(ImageReader):
def resize(self, width, height, dpi):
if width is None:
width = height * self._image.size[0] / self._image.size[1]
if height is None:
height = width * self._image.size[1] / self._image.size[0]
self._image.thumbnail(
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
self._data = None
return width, height
def _jpeg_fh(self):
# Bypass a reportlab-internal optimization that falls back to the original
# file handle if the file is a JPEG, and therefore does not respect the
# (smaller) size of the modified image.
return None
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic' identifier = 'classic'
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
+99 -2
View File
@@ -1,4 +1,5 @@
import copy import copy
import hashlib
import itertools import itertools
import logging import logging
import os import os
@@ -35,9 +36,9 @@ from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoice import ThumbnailingImageReader from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition from pretix.base.models import Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -339,6 +340,47 @@ DEFAULT_VARIABLES = OrderedDict((
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "") "evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}), }),
)) ))
DEFAULT_IMAGES = OrderedDict([])
@receiver(layout_image_variables, dispatch_uid="pretix_base_layout_image_variables_questions")
def images_from_questions(sender, *args, **kwargs):
def get_answer(op, order, event, question_id, etag):
a = None
if op.addon_to:
if 'answers' in getattr(op.addon_to, '_prefetched_objects_cache', {}):
try:
a = [a for a in op.addon_to.answers.all() if a.question_id == question_id][0]
except IndexError:
pass
else:
a = op.addon_to.answers.filter(question_id=question_id).first()
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
try:
a = [a for a in op.answers.all() if a.question_id == question_id][0]
except IndexError:
pass
else:
a = op.answers.filter(question_id=question_id).first()
if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")):
return None
else:
if etag:
return hashlib.sha1(a.file.name.encode()).hexdigest()
return a.file
d = {}
for q in sender.questions.all():
if q.type != Question.TYPE_FILE:
continue
d['question_{}'.format(q.identifier)] = {
'label': _('Question: {question}').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk, etag=False),
'etag': partial(get_answer, question_id=q.pk, etag=True),
}
return d
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions") @receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
@@ -369,6 +411,8 @@ def variables_from_questions(sender, *args, **kwargs):
d = {} d = {}
for q in sender.questions.all(): for q in sender.questions.all():
if q.type == Question.TYPE_FILE:
continue
d['question_{}'.format(q.pk)] = { d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question), 'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question), 'editor_sample': _('<Answer: {question}>').format(question=q.question),
@@ -387,6 +431,15 @@ def _get_ia_name_part(key, op, order, ev):
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else '' return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
def get_images(event):
v = copy.copy(DEFAULT_IMAGES)
for recv, res in layout_image_variables.send(sender=event):
v.update(res)
return v
def get_variables(event): def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES) v = copy.copy(DEFAULT_VARIABLES)
@@ -427,6 +480,7 @@ class Renderer:
self.layout = layout self.layout = layout
self.background_file = background_file self.background_file = background_file
self.variables = get_variables(event) self.variables = get_variables(event)
self.images = get_images(event)
self.event = event self.event = event
if self.background_file: if self.background_file:
self.bg_bytes = self.background_file.read() self.bg_bytes = self.background_file.read()
@@ -514,6 +568,47 @@ class Renderer:
return '(error)' return '(error)'
return '' return ''
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
ev = self._get_ev(op, order)
if not o['content'] or o['content'] not in self.images:
image_file = None
else:
try:
image_file = self.images[o['content']]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
image_file = None
if image_file:
ir = ThumbnailingImageReader(image_file)
try:
ir.resize(float(o['width']) * mm, float(o['height']) * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(
image=ir,
x=float(o['left']) * mm,
y=float(o['bottom']) * mm,
width=float(o['width']) * mm,
height=float(o['height']) * mm,
preserveAspectRatio=True,
anchor='c', # centered in frame
mask='auto'
)
else:
canvas.saveState()
canvas.setFillColorRGB(.8, .8, .8, alpha=1)
canvas.rect(
x=float(o['left']) * mm,
y=float(o['bottom']) * mm,
width=float(o['width']) * mm,
height=float(o['height']) * mm,
stroke=0,
fill=1,
)
canvas.restoreState()
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily'] font = o['fontfamily']
if o['bold']: if o['bold']:
@@ -572,6 +667,8 @@ class Renderer:
for o in self.layout: for o in self.layout:
if o['type'] == "barcodearea": if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o) self._draw_barcodearea(canvas, op, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textarea": elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o) self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby": elif o['type'] == "poweredby":
+26 -1
View File
@@ -613,6 +613,7 @@ If the email is associated with a specific user, e.g. a notification email, the
well, otherwise it will be ``None``. well, otherwise it will be ``None``.
""" """
layout_text_variables = EventPluginSignal() layout_text_variables = EventPluginSignal()
""" """
This signal is sent out to collect variables that can be used to display text in ticket-related PDF layouts. This signal is sent out to collect variables that can be used to display text in ticket-related PDF layouts.
@@ -627,11 +628,35 @@ dictionaries as values that contain keys like in the following example::
} }
} }
The evaluate member will be called with the order position, order and event as arguments. The event might The ``evaluate`` member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable. also be a subevent, if applicable.
""" """
layout_image_variables = EventPluginSignal()
"""
This signal is sent out to collect variables that can be used to display dynamic images in ticket-related PDF layouts.
Receivers are expected to return a dictionary with globally unique identifiers as keys and more
dictionaries as values that contain keys like in the following example::
return {
"profile": {
"label": _("Profile picture"),
"evaluate": lambda orderposition, order, event: ContentFile(b"some-image-data"),
"etag": lambda orderposition, order, event: hash(b"some-image-data")
}
}
The ``evaluate`` member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable. The return value of ``evaluate`` should be an instance of Django's ``File``
class and point to a valid JPEG or PNG file. If no image is available, ``evaluate`` should return ``None``.
The ``etag`` member will be called with the same arguments as ``evaluate`` but should return a ``str`` value
uniquely identifying the version of the file. This can be a hash of the file, but can also be something else.
If no image is available, ``etag`` should return ``None``. In some cases, this can speed up the implementation.
"""
timeline_events = EventPluginSignal() timeline_events = EventPluginSignal()
""" """
This signal is sent out to collect events for the time line shown on event dashboards. You are passed This signal is sent out to collect events for the time line shown on event dashboards. You are passed
@@ -228,6 +228,18 @@
id="toolbox-position-y"> id="toolbox-position-y">
</div> </div>
</div> </div>
<div class="row control-group rectsize">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-width">
</div>
<div class="col-sm-6">
<label>{% trans "Height (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-height">
</div>
</div>
<div class="row control-group squaresize poweredby"> <div class="row control-group squaresize poweredby">
<div class="col-sm-12"> <div class="col-sm-12">
<label>{% trans "Size (mm)" %}</label><br> <label>{% trans "Size (mm)" %}</label><br>
@@ -332,6 +344,17 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row control-group imagecontent">
<div class="col-sm-12">
<label>{% trans "Image content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-imagecontent">
<option value="">{% trans "Empty" %}</option>
{% for varname, var in images.items %}
<option value="{{ varname }}">{{ var.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text"> <div class="row control-group text">
<div class="col-sm-12"> <div class="col-sm-12">
<label>{% trans "Text content" %}</label><br> <label>{% trans "Text content" %}</label><br>
@@ -382,6 +405,10 @@
<span class="fa fa-image"></span> <span class="fa fa-image"></span>
{% trans "pretix Logo" %} {% trans "pretix Logo" %}
</button> </button>
<button class="btn btn-default btn-block" id="editor-add-image" disabled>
<span class="fa fa-image"></span>
{% trans "Dynamic image" %}
</button>
</div> </div>
</div> </div>
+5 -1
View File
@@ -22,7 +22,7 @@ from reportlab.lib.units import mm
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition
from pretix.base.pdf import get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
@@ -211,11 +211,15 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
def get_variables(self): def get_variables(self):
return get_variables(self.request.event) return get_variables(self.request.event)
def get_images(self):
return get_images(self.request.event)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['fonts'] = get_fonts() ctx['fonts'] = get_fonts()
ctx['pdf'] = self.get_current_background() ctx['pdf'] = self.get_current_background()
ctx['variables'] = self.get_variables() ctx['variables'] = self.get_variables()
ctx['images'] = self.get_images()
ctx['layout'] = json.dumps(self.get_current_layout()) ctx['layout'] = json.dumps(self.get_current_layout())
ctx['title'] = self.title ctx['title'] = self.title
ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales] ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales]
+22
View File
@@ -0,0 +1,22 @@
from PIL.Image import BICUBIC
from reportlab.lib.utils import ImageReader
class ThumbnailingImageReader(ImageReader):
def resize(self, width, height, dpi):
if width is None:
width = height * self._image.size[0] / self._image.size[1]
if height is None:
height = width * self._image.size[1] / self._image.size[0]
self._image.thumbnail(
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
self._data = None
return width, height
def _jpeg_fh(self):
# Bypass a reportlab-internal optimization that falls back to the original
# file handle if the file is a JPEG, and therefore does not respect the
# (smaller) size of the modified image.
return None
@@ -20,6 +20,32 @@ fabric.Poweredby = fabric.util.createClass(fabric.Image, {
fabric.Poweredby.fromObject = function (object, callback, forceAsync) { fabric.Poweredby.fromObject = function (object, callback, forceAsync) {
return fabric.Object._fromObject('Poweredby', object, callback, forceAsync); return fabric.Object._fromObject('Poweredby', object, callback, forceAsync);
}; };
fabric.Imagearea = fabric.util.createClass(fabric.Rect, {
type: 'imagearea',
initialize: function (text, options) {
options || (options = {});
this.callSuper('initialize', text, options);
this.set('label', options.label || '');
},
toObject: function () {
return fabric.util.object.extend(this.callSuper('toObject'), {});
},
_render: function (ctx) {
ctx.fillStyle = '#009'
this.callSuper('_render', ctx);
ctx.font = '12px Helvetica';
ctx.fillStyle = '#fff';
ctx.fillText(this.content, -this.width / 2, -this.height / 2 + 20, this.width);
},
});
fabric.Imagearea.fromObject = function (object, callback, forceAsync) {
return fabric.Object._fromObject('Imagearea', object, callback, forceAsync);
};
fabric.Barcodearea = fabric.util.createClass(fabric.Rect, { fabric.Barcodearea = fabric.util.createClass(fabric.Rect, {
type: 'barcodearea', type: 'barcodearea',
@@ -139,6 +165,15 @@ var editor = {
rotation: o.angle, rotation: o.angle,
align: o.textAlign, align: o.textAlign,
}); });
} else if (o.type === "imagearea") {
d.push({
type: "imagearea",
left: editor._px2mm(left).toFixed(2),
bottom: editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - top).toFixed(2),
height: editor._px2mm(o.height * o.scaleY).toFixed(2),
width: editor._px2mm(o.width * o.scaleX).toFixed(2),
content: o.content,
});
} else if (o.type === "barcodearea") { } else if (o.type === "barcodearea") {
d.push({ d.push({
type: "barcodearea", type: "barcodearea",
@@ -165,6 +200,13 @@ var editor = {
o = editor._add_qrcode(); o = editor._add_qrcode();
o.content = d.content; o.content = d.content;
o.scaleToHeight(editor._mm2px(d.size)); o.scaleToHeight(editor._mm2px(d.size));
} else if (d.type === "imagearea") {
o = editor._add_imagearea(d.content);
o.content = d.content;
o.setHeight(editor._mm2px(d.height));
o.setWidth(editor._mm2px(d.width));
o.setScaleX(1);
o.setScaleY(1);
} else if (d.type === "poweredby") { } else if (d.type === "poweredby") {
o = editor._add_poweredby(d.content); o = editor._add_poweredby(d.content);
o.content = d.content; o.content = d.content;
@@ -356,6 +398,10 @@ var editor = {
if (o.type === "barcodearea") { if (o.type === "barcodearea") {
$("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); $("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2));
} else if (o.type === "imagearea") {
$("#toolbox-height").val(editor._px2mm(o.height * o.scaleY).toFixed(2));
$("#toolbox-width").val(editor._px2mm(o.width * o.scaleX).toFixed(2));
$("#toolbox-imagecontent").val(o.content);
} else if (o.type === "poweredby") { } else if (o.type === "poweredby") {
$("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); $("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2));
$("#toolbox-poweredby-style").val(o.content); $("#toolbox-poweredby-style").val(o.content);
@@ -411,6 +457,16 @@ var editor = {
o.setScaleX(1); o.setScaleX(1);
o.setScaleY(1); o.setScaleY(1);
o.set('top', new_top) o.set('top', new_top)
} else if (o.type === "imagearea") {
var new_w = editor._mm2px($("#toolbox-width").val());
var new_h = editor._mm2px($("#toolbox-height").val());
new_top += o.height * o.scaleY - new_h;
o.setHeight(new_h);
o.setWidth(new_w);
o.setScaleX(1);
o.setScaleY(1);
o.set('top', new_top)
o.content = $("#toolbox-imagecontent").val();
} else if (o.type === "poweredby") { } else if (o.type === "poweredby") {
var new_h = Math.max(1, editor._mm2px($("#toolbox-squaresize").val())); var new_h = Math.max(1, editor._mm2px($("#toolbox-squaresize").val()));
new_top += o.height * o.scaleY - new_h; new_top += o.height * o.scaleY - new_h;
@@ -467,6 +523,8 @@ var editor = {
$("#toolbox-heading").text(gettext("Text object")); $("#toolbox-heading").text(gettext("Text object"));
} else if (o.type === "barcodearea") { } else if (o.type === "barcodearea") {
$("#toolbox-heading").text(gettext("Barcode area")); $("#toolbox-heading").text(gettext("Barcode area"));
} else if (o.type === "imagearea") {
$("#toolbox-heading").text(gettext("Image area"));
} else if (o.type === "poweredby") { } else if (o.type === "poweredby") {
$("#toolbox-heading").text(gettext("Powered by pretix")); $("#toolbox-heading").text(gettext("Powered by pretix"));
} else { } else {
@@ -530,6 +588,22 @@ var editor = {
return rect; return rect;
}, },
_add_imagearea: function () {
var rect = new fabric.Imagearea({
left: 100,
top: 100,
width: 100,
height: 100,
lockRotation: true,
fill: '#666',
content: '',
});
rect.setControlsVisibility({'mtr': false});
editor.fabric.add(rect);
editor._create_savepoint();
return rect;
},
_add_qrcode: function () { _add_qrcode: function () {
var rect = new fabric.Barcodearea({ var rect = new fabric.Barcodearea({
left: 100, left: 100,
@@ -795,6 +869,7 @@ var editor = {
editor.$cva = $("#editor-canvas-area"); editor.$cva = $("#editor-canvas-area");
editor._load_pdf(); editor._load_pdf();
$("#editor-add-qrcode, #editor-add-qrcode-lead").click(editor._add_qrcode); $("#editor-add-qrcode, #editor-add-qrcode-lead").click(editor._add_qrcode);
$("#editor-add-image").click(editor._add_imagearea);
$("#editor-add-text").click(editor._add_text); $("#editor-add-text").click(editor._add_text);
$("#editor-add-poweredby").click(function() {editor._add_poweredby("dark")}); $("#editor-add-poweredby").click(function() {editor._add_poweredby("dark")});
editor.$cva.get(0).tabIndex = 1000; editor.$cva.get(0).tabIndex = 1000;
@@ -15,14 +15,18 @@ body {
} }
#toolbox .position, #toolbox .position,
#toolbox .squaresize, #toolbox .squaresize,
#toolbox .rectsize,
#toolbox .poweredby, #toolbox .poweredby,
#toolbox[data-type] .pdf-info, #toolbox[data-type] .pdf-info,
#toolbox .text, #toolbox .text,
#toolbox .imagecontent,
#toolbox .object-buttons { #toolbox .object-buttons {
display: none; display: none;
} }
#toolbox[data-type] .position, #toolbox[data-type] .position,
#toolbox[data-type=barcodearea] .squaresize, #toolbox[data-type=barcodearea] .squaresize,
#toolbox[data-type=imagearea] .rectsize,
#toolbox[data-type=imagearea] .imagecontent,
#toolbox[data-type=poweredby] .poweredby, #toolbox[data-type=poweredby] .poweredby,
#toolbox[data-type=text] .text, #toolbox[data-type=text] .text,
#toolbox[data-type=textarea] .text, #toolbox[data-type=textarea] .text,