{% for locale, out in output.items %}
diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py
index be5c18c68..5b46cac2c 100644
--- a/src/pretix/plugins/sendmail/views.py
+++ b/src/pretix/plugins/sendmail/views.py
@@ -46,12 +46,10 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import get_template
from django.urls import reverse
from django.utils.functional import cached_property
-from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.generic import DeleteView, FormView, ListView, TemplateView
-from pretix.base.email import get_available_placeholders
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
from pretix.base.models.event import SubEvent
@@ -63,7 +61,8 @@ from pretix.plugins.sendmail.tasks import (
)
from ...base.services.mail import prefix_subject
-from ...helpers.format import format_map
+from ...base.services.placeholders import get_sample_context
+from ...helpers.format import SafeFormatter, format_map
from ...helpers.models import modelcopy
from . import forms
from .models import Rule, ScheduledMail
@@ -191,17 +190,15 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
if self.request.POST.get("action") != "send":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
- context_dict = {}
- for k, v in get_available_placeholders(self.request.event, self.context_parameters).items():
- context_dict[k] = '
{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
- subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[])
+ context_dict = get_sample_context(self.request.event, self.context_parameters)
+ subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
message = form.cleaned_data['message'].localize(l)
- preview_text = markdown_compile_email(format_map(message, context_dict))
+ preview_text = format_map(
+ markdown_compile_email(format_map(message, context_dict)),
+ context_dict,
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
+ )
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
@@ -603,31 +600,6 @@ class CreateRule(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
def form_valid(self, form):
- self.output = {}
-
- if self.request.POST.get("action") == "preview":
- for l in self.request.event.settings.locales:
- with language(l, self.request.event.settings.region):
- context_dict = {}
- for k, v in get_available_placeholders(self.request.event, ['event', 'order',
- 'position_or_address']).items():
- context_dict[k] = '
{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
- subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[])
- preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
- template = form.cleaned_data['template'].localize(l)
- preview_text = markdown_compile_email(format_map(template, context_dict))
-
- self.output[l] = {
- 'subject': _('Subject: {subject}').format(subject=preview_subject),
- 'html': preview_text,
- }
-
- return self.get(self.request, *self.args, **self.kwargs)
-
messages.success(self.request, _('Your rule has been created.'))
form.instance.event = self.request.event
@@ -685,17 +657,15 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView):
for lang in self.request.event.settings.locales:
with language(lang, self.request.event.settings.region):
- placeholders = {}
- for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items():
- placeholders[k] = '
{}'.format(
- _('This value will be replaced based on dynamic parameters.'),
- escape(v.render_sample(self.request.event))
- )
-
- subject = bleach.clean(self.object.subject.localize(lang), tags=[])
+ placeholders = get_sample_context(self.request.event, ['event', 'order', 'position_or_address'])
+ subject = bleach.clean(self.object.subject.localize(lang), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, placeholders), highlight=True)
template = self.object.template.localize(lang)
- preview_text = markdown_compile_email(format_map(template, placeholders))
+ preview_text = format_map(
+ markdown_compile_email(format_map(template, placeholders)),
+ placeholders,
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
+ )
o[lang] = {
'subject': _('Subject: {subject}'.format(subject=preview_subject)),
diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py
index 3f54e345a..69ff425fc 100644
--- a/src/pretix/plugins/stripe/payment.py
+++ b/src/pretix/plugins/stripe/payment.py
@@ -113,7 +113,7 @@ logger = logging.getLogger('pretix.plugins.stripe')
# - giropay: (deprecated)
# - iDEAL: ✓
# - P24: ✓
-# - Sofort: ✓
+# - Sofort: (deprecated)
# - FPX: ✗
# - PayNow: ✗
# - UPI: ✗
@@ -152,7 +152,7 @@ logger = logging.getLogger('pretix.plugins.stripe')
# - Link: ✓ (PaymentRequestButton)
# - Cash App Pay: ✗
# - PayPal: ✓ (No settings UI yet)
-# - MobilePay: ✗
+# - MobilePay: ✓
# - Alipay: ✓
# - WeChat Pay: ✓
# - GrabPay: ✓
@@ -388,21 +388,6 @@ class StripeSettingsHolder(BasePaymentProvider):
}
),
)),
- ('method_sofort',
- forms.BooleanField(
- label=_('SOFORT'),
- disabled=self.event.currency != 'EUR',
- help_text=(
- _('Stripe is in the process of removing this payment method. If you created your Stripe '
- 'account after November 2023, you cannot use this payment method.') +
- '
%s
' % _(
- 'Despite the name, Sofort payments via Stripe are
not processed '
- 'instantly but might take up to
14 days to be confirmed in some cases. '
- 'Please only activate this payment method if your payment term allows for this lag.'
- )
- ),
- required=False,
- )),
('method_eps',
forms.BooleanField(
label=_('EPS'),
@@ -492,8 +477,19 @@ class StripeSettingsHolder(BasePaymentProvider):
# label=_('PayPal'),
# disabled=self.event.currency not in [
# 'EUR', 'GBP', 'USD', 'CHF', 'CZK', 'DKK', 'NOK', 'PLN', 'SEK', 'AUD', 'CAD', 'HKD', 'NZD', 'SGD'
- # ]
+ # ],
+ # help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
+ # 'before they work properly.'),
+ # required=False,
# )),
+ ('method_mobilepay',
+ forms.BooleanField(
+ label=_('MobilePay'),
+ disabled=self.event.currency not in ['DKK', 'EUR', 'NOK', 'SEK'],
+ help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
+ 'before they work properly.'),
+ required=False,
+ )),
] + extra_fields + list(super().settings_form_fields.items()) + moto_settings
)
if not self.settings.connect_client_id or self.settings.secret_key:
@@ -1600,6 +1596,17 @@ class StripeSofort(StripeRedirectMethod):
method = 'sofort'
redirect_in_widget_allowed = False
+ def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
+ # Stripe<>Sofort is shut down November 29th
+ return super().is_allowed(request, total) and now() < datetime(
+ 2024, 11, 29, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")
+ )
+
+ def order_change_allowed(self, order: Order, request: HttpRequest=None) -> bool:
+ return super().order_change_allowed(order, request) and now() < datetime(
+ 2024, 11, 29, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")
+ )
+
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/stripe/checkout_payment_form_simple.html')
ctx = {
@@ -1853,3 +1860,21 @@ class StripeTwint(StripeRedirectMethod):
"type": "twint",
},
}
+
+
+class StripeMobilePay(StripeRedirectMethod):
+ identifier = 'stripe_mobilepay'
+ verbose_name = 'MobilePay via Stripe'
+ public_name = 'MobilePay'
+ method = 'mobilepay'
+ confirmation_method = 'automatic'
+ explanation = _(
+ 'This payment method is available to MobilePay app users in Denmark and Finland. Please have your app ready.'
+ )
+
+ def _payment_intent_kwargs(self, request, payment):
+ return {
+ "payment_method_data": {
+ "type": "mobilepay",
+ },
+ }
diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py
index bf6dc0853..29189b7d2 100644
--- a/src/pretix/plugins/stripe/signals.py
+++ b/src/pretix/plugins/stripe/signals.py
@@ -46,8 +46,8 @@ from pretix.presale.signals import html_head, process_response
def register_payment_provider(sender, **kwargs):
from .payment import (
StripeAffirm, StripeAlipay, StripeBancontact, StripeCC, StripeEPS,
- StripeGiropay, StripeIdeal, StripeKlarna, StripeMultibanco,
- StripePayPal, StripePrzelewy24, StripeRevolutPay,
+ StripeGiropay, StripeIdeal, StripeKlarna, StripeMobilePay,
+ StripeMultibanco, StripePayPal, StripePrzelewy24, StripeRevolutPay,
StripeSEPADirectDebit, StripeSettingsHolder, StripeSofort, StripeSwish,
StripeTwint, StripeWeChatPay,
)
@@ -55,7 +55,7 @@ def register_payment_provider(sender, **kwargs):
return [
StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact,
StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeRevolutPay, StripeWeChatPay,
- StripeSEPADirectDebit, StripeAffirm, StripeKlarna, StripePayPal, StripeSwish, StripeTwint,
+ StripeSEPADirectDebit, StripeAffirm, StripeKlarna, StripePayPal, StripeSwish, StripeTwint, StripeMobilePay
]
diff --git a/src/pretix/plugins/ticketoutputpdf/api.py b/src/pretix/plugins/ticketoutputpdf/api.py
index efcbba438..b526ccf59 100644
--- a/src/pretix/plugins/ticketoutputpdf/api.py
+++ b/src/pretix/plugins/ticketoutputpdf/api.py
@@ -19,21 +19,33 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
#
.
#
+from datetime import timedelta
+
+from celery.result import AsyncResult
from django.conf import settings
from django.db import transaction
from django.db.models import QuerySet
+from django.http import Http404
+from django.shortcuts import get_object_or_404
from django.utils.functional import lazy
-from rest_framework import serializers, viewsets
+from django.utils.timezone import now
+from django_scopes import scopes_disabled
+from rest_framework import serializers, status, viewsets
+from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
+from rest_framework.response import Response
+from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from ...api.serializers.fields import UploadedFileField
-from ...base.models import SalesChannel
+from ...base.models import CachedFile, OrderPosition, SalesChannel
from ...base.pdf import PdfLayoutValidator
+from ...helpers.http import ChunkBasedFileResponse
from ...multidomain.utils import static_absolute
from .models import TicketLayout, TicketLayoutItem
+from .tasks import bulk_render
class ItemAssignmentSerializer(I18nAwareModelSerializer):
@@ -156,3 +168,123 @@ class TicketLayoutItemViewSet(viewsets.ReadOnlyModelViewSet):
**super().get_serializer_context(),
'event': self.request.event,
}
+
+
+with scopes_disabled():
+ class RenderJobPartSerializer(serializers.Serializer):
+ orderposition = serializers.PrimaryKeyRelatedField(
+ queryset=OrderPosition.objects.none(),
+ required=True,
+ allow_null=False,
+ )
+ override_layout = serializers.PrimaryKeyRelatedField(
+ queryset=TicketLayout.objects.none(),
+ required=False,
+ allow_null=True,
+ )
+ override_channel = serializers.SlugRelatedField(
+ queryset=SalesChannel.objects.none(),
+ slug_field='identifier',
+ required=False,
+ allow_null=True,
+ )
+
+
+class RenderJobSerializer(serializers.Serializer):
+ parts = RenderJobPartSerializer(many=True)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['parts'].child.fields['orderposition'].queryset = OrderPosition.objects.filter(order__event=self.context['event'])
+ self.fields['parts'].child.fields['override_layout'].queryset = self.context['event'].ticket_layouts.all()
+ self.fields['parts'].child.fields['override_channel'].queryset = self.context['event'].organizer.sales_channels.all()
+
+ def validate(self, attrs):
+ if len(attrs["parts"]) > 1000:
+ raise ValidationError({"parts": ["Please do not submit more than 1000 parts."]})
+ return super().validate(attrs)
+
+
+class TicketRendererViewSet(viewsets.ViewSet):
+ permission = 'can_view_orders'
+
+ def get_serializer_kwargs(self):
+ return {}
+
+ def list(self, request, *args, **kwargs):
+ raise Http404()
+
+ def retrieve(self, request, *args, **kwargs):
+ raise Http404()
+
+ def update(self, request, *args, **kwargs):
+ raise Http404()
+
+ def partial_update(self, request, *args, **kwargs):
+ raise Http404()
+
+ def destroy(self, request, *args, **kwargs):
+ raise Http404()
+
+ @action(detail=False, methods=['GET'], url_name='download', url_path='download/(?P
[^/]+)/(?P[^/]+)')
+ def download(self, *args, **kwargs):
+ cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
+ if cf.file:
+ resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
+ resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
+ return resp
+ elif not settings.HAS_CELERY:
+ return Response(
+ {'status': 'failed', 'message': 'Unknown file ID or export failed'},
+ status=status.HTTP_410_GONE
+ )
+
+ res = AsyncResult(kwargs['asyncid'])
+ if res.failed():
+ if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError':
+ msg = res.info['exc_message']
+ else:
+ msg = 'Internal error'
+ return Response(
+ {'status': 'failed', 'message': msg},
+ status=status.HTTP_410_GONE
+ )
+
+ return Response(
+ {
+ 'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
+ },
+ status=status.HTTP_409_CONFLICT
+ )
+
+ @action(detail=False, methods=['POST'])
+ def render_batch(self, *args, **kwargs):
+ serializer = RenderJobSerializer(data=self.request.data, context={
+ "event": self.request.event,
+ })
+ serializer.is_valid(raise_exception=True)
+
+ cf = CachedFile(web_download=False)
+ cf.date = now()
+ cf.expires = now() + timedelta(hours=24)
+ cf.save()
+ async_result = bulk_render.apply_async(args=(
+ self.request.event.id,
+ str(cf.id),
+ [
+ {
+ "orderposition": r["orderposition"].id,
+ "override_layout": r["override_layout"].id if r.get("override_layout") else None,
+ "override_channel": r["override_channel"].id if r.get("override_channel") else None,
+ } for r in serializer.validated_data["parts"]
+ ]
+ ))
+
+ url_kwargs = {
+ 'asyncid': str(async_result.id),
+ 'cfid': str(cf.id),
+ }
+ url_kwargs.update(self.kwargs)
+ return Response({
+ 'download': reverse('api-v1:ticketpdfrenderer-download', kwargs=url_kwargs, request=self.request)
+ }, status=status.HTTP_202_ACCEPTED)
diff --git a/src/pretix/plugins/ticketoutputpdf/tasks.py b/src/pretix/plugins/ticketoutputpdf/tasks.py
index 39ff685a4..c28f8e300 100644
--- a/src/pretix/plugins/ticketoutputpdf/tasks.py
+++ b/src/pretix/plugins/ticketoutputpdf/tasks.py
@@ -19,18 +19,26 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
#