Compare commits

..

42 Commits

Author SHA1 Message Date
Richard Schreiber
d28fcf2319 fix missing int str conversion 2026-03-16 15:29:46 +01:00
Richard Schreiber
d1df2e5213 improve code style 2026-03-16 15:16:02 +01:00
Richard Schreiber
5b93687611 imrpove code style 2026-03-16 15:15:29 +01:00
Richard Schreiber
26ccfee67b do not force download on AnswerDownload 2026-03-16 15:14:03 +01:00
Richard Schreiber
bc1eff4e56 do not force download on tickets in backend 2026-03-16 15:13:42 +01:00
Richard Schreiber
c53b10e04b Invoice preview inline 2026-03-16 15:10:55 +01:00
Richard Schreiber
12295fde84 improve code style 2026-03-16 15:08:33 +01:00
Richard Schreiber
094193efc8 remove unused code 2026-03-16 15:04:12 +01:00
Richard Schreiber
3b2b00bf67 use as_attachment for file responses in control 2026-03-16 15:03:35 +01:00
Richard Schreiber
35822e7f25 do not ignore csp even for disposition=inline 2026-03-16 15:03:06 +01:00
Richard Schreiber
e68189a2ea use as_attachment for API FileResponses 2026-03-16 15:02:29 +01:00
Richard Schreiber
e4dae627d6 Use inline for PDF-view in pretix-control editor 2026-03-16 14:48:12 +01:00
Richard Schreiber
3550a23e33 Use FileResponse filename for AnswerDownload 2026-03-16 14:44:33 +01:00
Richard Schreiber
2d988406e3 Display invoice and tickets inline in browser (Z#23225892) 2026-03-16 14:38:36 +01:00
Richard Schreiber
ed1966bc96 Improve autofill for peppol BE (Z#23224796) (#5992) 2026-03-16 10:48:05 +01:00
Ruud Hendrickx
fad5284f25 Translations: Update Dutch (Belgium)
Currently translated at 75.7% (4741 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
f57530d3ff Translations: Update Dutch (Belgium)
Currently translated at 75.6% (4736 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
1427edf5ab Translations: Update Dutch (Belgium)
Currently translated at 74.7% (4676 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
4898475d56 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6257 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_Informal/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
cdacc84553 Translations: Update Dutch
Currently translated at 100.0% (6257 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
ef483d5229 Translations: Update Dutch (Belgium)
Currently translated at 73.0% (4570 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-03-16 10:47:43 +01:00
Pedro Orlando
ad6f5a7b54 Translations: Update Portuguese (Brazil)
Currently translated at 95.6% (5985 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/pt_BR/

powered by weblate
2026-03-16 10:47:43 +01:00
Demir Kaya
ecc49d453d Translations: Update Turkish
Currently translated at 39.7% (2488 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/tr/

powered by weblate
2026-03-16 10:47:43 +01:00
Pedro Orlando
c45070b190 Translations: Update Portuguese (Brazil)
Currently translated at 95.2% (5957 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/pt_BR/

powered by weblate
2026-03-16 10:47:43 +01:00
Ruud Hendrickx
aea2a1ca10 Translations: Update Dutch (Belgium)
Currently translated at 72.6% (4548 of 6257 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-03-16 10:47:43 +01:00
Lukas Bockstaller
d791b9e108 fix rst (#5993) 2026-03-16 09:37:45 +01:00
dependabot[bot]
2c9802d1cb Update pyjwt requirement from ==2.11.* to ==2.12.* (#5984)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 09:27:49 +01:00
Lukas Bockstaller
c39f1bfcc2 handle gift card payment via create order api endpoint (Z#23224691) (#5968)
* adds safeguard to prevent empty giftcard transactions on giftcards of value 0.00

* implement giftcard payment via order create

* styling

* let create_transactions() handle all the mailing

* docs

* provide more context for failed transactions

* documentation lectoring

* reject duplicate gift card secrets

* make payment_provider and use_gift_cards exclusive

* handle unknown gift cards

* Apply suggestion from @pajowu

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Update src/pretix/control/templates/pretixcontrol/giftcards/payment.html

Co-authored-by: pajowu <engelhardt@pretix.eu>

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-03-16 08:51:27 +01:00
Richard Schreiber
894128deab Fix log-display for team.invite.deleted (#5988) 2026-03-16 08:21:45 +01:00
luelista
3352ee2bbe Limits of the time machine feature (Z#23212144) (#5952)
* Add note about limits of the time machine feature
* Always check voucher validity against real time, not time machine time
2026-03-12 18:09:16 +01:00
Martin Gross
af28785fb9 Stripe: iDEAL -> iDEAL | Wero rebrand 2026-03-12 13:37:35 +01:00
Martin Gross
54e4957e89 Stripe: Update list of supported payment methods 2026-03-12 13:37:06 +01:00
Richard Schreiber
f3597f1a44 Fix orderlist export with no events (#5936) 2026-03-11 08:08:41 +01:00
Raphael Michel
2e01887e79 Invoice address: Special validation for Belgium (Z#23224796) (#5970)
* Invoice address: Special validation for Belgium (Z#23224796)

* Update src/pretix/base/invoicing/peppol.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-03-10 09:57:44 +01:00
Raphael Michel
5a7e7fbde3 Event lists: Show sales channels (Z#23225483) (#5967) 2026-03-10 09:56:29 +01:00
Raphael Michel
7b296107c5 Invoice address: Fix broken autofill for Peppol ID (Z#23224796) (#5971)
* Invoice address: Fix broken autofill for Peppol ID (Z#23224796)

* Fix wrong prefix
2026-03-10 09:54:54 +01:00
Raphael Michel
4f449ce6b4 Mail: Handle all rendering in mail.py, return values for log (#5895)
* Mail: Handle all rendering in mail.py, return values for log

* Apply suggestions from code review
2026-03-10 09:53:09 +01:00
Raphael Michel
e6ea8fb5bf Error pages: Load event theme if available (Z#23224853) (#5972) 2026-03-09 20:11:01 +01:00
Raphael Michel
547910beec Voucher CSV download: Do not output "any product" (Z#23224795) (#5969) 2026-03-09 18:26:54 +01:00
Raphael Michel
eef1560ede Order modification: Remove warning when invoice is not yet generated (Z#23226423) (#5966) 2026-03-09 18:16:37 +01:00
Raphael Michel
3d68bbb619 Order change manager: Recalculate tax of zero-valued positions (Z#23223874) (#5938) 2026-03-09 18:13:14 +01:00
Raphael Michel
dc4556d428 PDF editor: add file size to label (Z#23226663) (#5965) 2026-03-09 18:10:57 +01:00
44 changed files with 1397 additions and 645 deletions

View File

@@ -117,6 +117,8 @@ cancellation_date datetime Time of order c
reliable for orders that have been cancelled,
reactivated and cancelled again.
plugin_data object Additional data added by plugins.
use_gift_cards list of strings List of unique gift card secrets that are used to pay
for this order.
===================================== ========================== =======================================================
@@ -156,6 +158,10 @@ plugin_data object Additional data
The ``tax_rounding_mode`` attribute has been added.
.. versionchanged:: 2026.03
The ``use_gift_cards`` attribute has been added.
.. _order-position-resource:
Order position resource
@@ -987,8 +993,6 @@ Creating orders
* does not support file upload questions
* does not support redeeming gift cards
* does not support or validate memberships
@@ -1095,6 +1099,14 @@ Creating orders
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
Used to be ``send_mail`` before pretix 3.14.
* ``use_gift_cards`` (optional) The provided gift cards will be used to pay for this order. They will be debited and
all the necessary payment records for these transactions will be created. The gift cards will be used in sequence to
pay for the order. Processing of the gift cards stops as soon as the order is payed for. All gift card transactions
are listed under ``payments`` in the response.
This option can only be used with orders that are in the pending state.
The ``use_gift_cards`` attribute can not be combined with ``payment_info`` and ``payment_provider`` fields. If the
order isn't completely paid after its creation with ``use_gift_cards``, then a subsequent request to the payment
endpoint is needed.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these

View File

@@ -73,7 +73,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.11.*",
"PyJWT==2.12.*",
"phonenumberslite==9.0.*",
"Pillow==12.1.*",
"pretix-plugin-build",

View File

@@ -53,7 +53,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import (
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
@@ -62,6 +62,7 @@ from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret, Transaction,
)
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
@@ -1200,6 +1201,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
)
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1215,7 +1217,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1310,6 +1312,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False)
gift_card_secrets = validated_data.pop('use_gift_cards') if 'use_gift_cards' in validated_data else []
if (payment_provider is not None or payment_info != '{}') and len(gift_card_secrets) > 0:
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is not compatible with payment_provider or payment_info']})
if validated_data.get('status') != Order.STATUS_PENDING and len(gift_card_secrets) > 0:
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is only supported for orders that are created as pending']})
if len(set(gift_card_secrets)) != len(gift_card_secrets):
raise ValidationError({"use_gift_cards": ['Multiple copies of the same gift card secret are not allowed']})
if not validated_data.get("sales_channel"):
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
@@ -1794,6 +1804,45 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if order.total != Decimal('0.00') and order.event.currency == "XXX":
raise ValidationError('Paid products not supported without a valid currency.')
for gift_card_secret in gift_card_secrets:
try:
if order.status != Order.STATUS_PAID:
gift_card_payment_provider = GiftCardPayment(event=order.event)
gc = order.event.organizer.accepted_gift_cards.get(
secret=gift_card_secret
)
payment = order.payments.create(
amount=min(order.pending_sum, gc.value),
provider=gift_card_payment_provider.identifier,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
'retry': True
},
state=OrderPayment.PAYMENT_STATE_CREATED
)
gift_card_payment_provider.execute_payment(request=None, payment=payment, is_early_special_case=True)
if order.pending_sum <= Decimal('0.00'):
order.status = Order.STATUS_PAID
except PaymentException:
pass
except GiftCard.DoesNotExist as e:
payment = order.payments.create(
amount=order.pending_sum,
provider=GiftCardPayment.identifier,
info_data={
'gift_card_secret': gift_card_secret,
},
state=OrderPayment.PAYMENT_STATE_CREATED
)
payment.fail(info={**payment.info_data, 'error': str(e)},
send_mail=False)
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
order.status = Order.STATUS_PAID
order.save()

View File

@@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
@@ -1301,14 +1304,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
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 FileResponse(
answer.file,
filename='{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs):
@@ -1363,15 +1369,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
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 FileResponse(
image_file,
filename='{}-{}-{}-{}.{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
@@ -1397,12 +1406,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
@@ -1979,9 +1991,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file:
raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
return FileResponse(
invoice.file.file,
filename='{}.pdf"'.format(invoice.number),
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):

View File

@@ -315,8 +315,9 @@ class OrderListExporter(MultiSheetListExporter):
for id, vn in payment_methods:
headers.append(_('Paid by {method}').format(method=vn))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
if self.event_object_cache:
# get meta_data labels from first cached event if any
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
full_fee_sum_cache = {
@@ -503,8 +504,9 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
if self.event_object_cache:
# get meta_data labels from first cached event if any
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
yield self.ProgressSetTotal(total=qs.count())
@@ -707,9 +709,9 @@ class OrderListExporter(MultiSheetListExporter):
_('Position order link')
]
# get meta_data labels from first cached event
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
if has_subevents:
# get meta_data labels from first cached event
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
headers += meta_data_labels
yield headers

View File

@@ -33,8 +33,7 @@ from pretix.base.invoicing.transmission import (
transmission_types,
)
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import mail, render_mail
from pretix.helpers.format import format_map
from pretix.base.services.mail import mail
@transmission_types.new()
@@ -134,9 +133,7 @@ class EmailTransmissionProvider(TransmissionProvider):
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
# Do not set to completed because that is done by the email sending task
subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
outgoing_mail = mail(
[recipient],
subject,
template,
@@ -151,19 +148,10 @@ class EmailTransmissionProvider(TransmissionProvider):
plain_text_only=True,
no_order_links=True,
)
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
)
if outgoing_mail:
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data=outgoing_mail.log_data()
)

View File

@@ -220,3 +220,20 @@ class OutgoingMail(models.Model):
error_log_action_type = 'pretix.email.error'
log_target = None
return log_target, error_log_action_type
def log_data(self):
return {
"subject": self.subject,
"message": self.body_plain,
"to": self.to,
"cc": self.cc,
"bcc": self.bcc,
"invoices": [i.pk for i in self.should_attach_invoices.all()],
"attach_tickets": self.should_attach_tickets,
"attach_ical": self.should_attach_ical,
"attach_other_files": self.should_attach_other_files,
"attach_cached_files": [cf.filename for cf in self.should_attach_cached_files.all()],
"position": self.orderposition.positionid if self.orderposition else None,
}

View File

@@ -87,7 +87,6 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import FormattedString, format_map
from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var
from ._transactions import (
@@ -1167,7 +1166,7 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import mail
if not self.email and not (position and position.attendee_email):
return
@@ -1177,32 +1176,20 @@ class Order(LockModel, LoggedModel):
if position and position.attendee_email:
recipient = position.attendee_email
email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
outgoing_mail = mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
)
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
if outgoing_mail:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data=outgoing_mail.log_data(),
)
def resend_link(self, user=None, auth=None):
with language(self.locale, self.event.settings.region):
@@ -2900,17 +2887,14 @@ class OrderPosition(AbstractPosition):
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import mail
if not self.attendee_email:
return
with language(self.order.locale, self.order.event.settings.region):
recipient = self.attendee_email
email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
outgoing_mail = mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
position=self,
@@ -2919,21 +2903,13 @@ class OrderPosition(AbstractPosition):
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
self.order.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
)
if outgoing_mail:
self.order.log_action(
log_entry_type,
user=user,
auth=auth,
data=outgoing_mail.log_data(),
)
def resend_link(self, user=None, auth=None):

View File

@@ -34,10 +34,9 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import User, Voucher
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import mail
from pretix.helpers import OF_SELF
from ...helpers.format import format_map
from ...helpers.names import build_name
from .base import LoggedModel
from .event import Event, SubEvent
@@ -273,9 +272,7 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region):
recipient = self.email
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
outgoing_mail = mail(
recipient, subject, template, context,
self.event,
self.locale,
@@ -285,18 +282,13 @@ class WaitingListEntry(LoggedModel):
attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
if outgoing_mail:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data=outgoing_mail.log_data(),
)
@staticmethod
def clean_itemvar(event, item, variation):

View File

@@ -1295,6 +1295,7 @@ class ManualPayment(BasePaymentProvider):
def format_map(self, order, payment):
return {
# Possible placeholder injection, we should make sure to never include user-controlled variables here
'order': order.code,
'amount': payment.amount,
'currency': self.event.currency,
@@ -1525,16 +1526,26 @@ class GiftCardPayment(BasePaymentProvider):
def payment_control_render(self, request, payment) -> str:
from .models import GiftCard
if 'gift_card' in payment.info_data:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
if any(key in payment.info_data for key in ('gift_card', 'error')):
template = get_template('pretixcontrol/giftcards/payment.html')
ctx = {
'request': request,
'event': self.event,
'gc': gc,
**({'error': payment.info_data[
'error']} if 'error' in payment.info_data else {}),
**({'gift_card_secret': payment.info_data[
'gift_card_secret']} if 'gift_card_secret' in payment.info_data else {})
}
return template.render(ctx)
try:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
ctx = {
'gc': gc,
}
except GiftCard.DoesNotExist:
pass
finally:
return template.render(ctx)
def payment_control_render_short(self, payment: OrderPayment) -> str:
d = payment.info_data
@@ -1549,12 +1560,16 @@ class GiftCardPayment(BasePaymentProvider):
try:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
except GiftCard.DoesNotExist:
return {}
return {
**({'error': payment.info_data[
'error']} if 'error' in payment.info_data else {})
}
return {
'gift_card': {
'id': gc.pk,
'secret': gc.secret,
'organizer': gc.issuer.slug
'organizer': gc.issuer.slug,
** ({'error': payment.info_data['error']} if 'error' in payment.info_data else {})
}
}
@@ -1626,6 +1641,8 @@ class GiftCardPayment(BasePaymentProvider):
raise PaymentException(_("This gift card does not support this currency."))
if not gc.accepted_by(self.event.organizer):
raise PaymentException(_("This gift card is not accepted by this event organizer."))
if gc.value <= Decimal("0.00"):
raise PaymentException(_("All credit on this gift card has been used."))
if payment.amount > gc.value:
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
if gc.testmode and not payment.order.testmode:
@@ -1655,7 +1672,7 @@ class GiftCardPayment(BasePaymentProvider):
}
)
except PaymentException as e:
payment.fail(info={'error': str(e)})
payment.fail(info={**payment.info_data, 'error': str(e)}, send_mail=not is_early_special_case)
raise e
def payment_is_valid_session(self, request: HttpRequest) -> bool:

View File

@@ -45,7 +45,6 @@ from pretix.base.services.tax import split_fee_for_taxes
from pretix.base.templatetags.money import money_filter
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map
logger = logging.getLogger(__name__)
@@ -55,7 +54,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
mail(
wle.email,
format_map(subject, email_context),
str(subject),
message,
email_context,
wle.event,
@@ -73,9 +72,8 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
real_subject = format_map(subject, email_context)
order.send_mail(
real_subject, message, email_context,
subject, message, email_context,
'pretix.event.order.email.event_canceled',
user,
)
@@ -85,14 +83,13 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = format_map(subject, email_context)
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event,
refund_amount=refund_amount,
position_or_address=p,
order=order, position=p)
order.send_mail(
real_subject, message, email_context,
subject, message, email_context,
'pretix.event.order.email.event_canceled',
position=p,
user=user

View File

@@ -334,7 +334,8 @@ def _check_position_constraints(
raise CartPositionError(error_messages['voucher_invalid_subevent'])
# Voucher expired
if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
# (checked using real_now_dt as vouchers influence quota calculations)
if voucher and voucher.valid_until and voucher.valid_until < real_now_dt:
raise CartPositionError(error_messages['voucher_expired'])
# Subevent has been disabled

View File

@@ -149,13 +149,13 @@ def prefix_subject(settings_holder, subject, highlight=False):
return subject
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None,
sensitive: bool=False):
sensitive: bool=False) -> Optional[OutgoingMail]:
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -335,14 +335,26 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
should_attach_other_files=attach_other_files or [],
sensitive=sensitive,
)
m._prefetched_objects_cache = {}
if invoices and not position:
m.should_attach_invoices.add(*invoices)
# Hack: For logging, we'll later make a `should_attach_invoices.all()` call. We can prevent a useless
# DB query by filling the cache
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = invoices
else:
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = Invoice.objects.none()
if attach_cached_files:
cf_list = []
for cf in attach_cached_files:
if not isinstance(cf, CachedFile):
m.should_attach_cached_files.add(CachedFile.objects.get(pk=cf))
else:
m.should_attach_cached_files.add(cf)
cf = CachedFile.objects.get(pk=cf)
m.should_attach_cached_files.add(cf)
cf_list.append(cf)
# Hack: For logging, we'll later make a `should_attach_cached_files.all()` call. We can prevent a useless
# DB query by filling the cache
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = cf_list
else:
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = CachedFile.objects.none()
send_task = mail_send_task.si(
outgoing_mail=m.id
@@ -364,6 +376,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
lambda: chain(*task_chain).apply_async()
)
return m
class CustomEmail(EmailMultiAlternatives):
def _create_mime_attachment(self, content, mimetype):

View File

@@ -1799,8 +1799,6 @@ class OrderChangeManager:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
if not tax_rule:
continue
if not pos.price:
continue
try:
new_rate = tax_rule.tax_rate_for(ia)
@@ -1817,7 +1815,9 @@ class OrderChangeManager:
override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True
if pos.price:
# We do not consider the invoice dirty if only 0€-valued taxes are changed
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee):
self._totaldiff_guesstimate -= fee.value

View File

@@ -39,7 +39,7 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
with language(event.settings.locale):
email_context = get_email_context(event=event, name=r.get('name') or '',
voucher_list=[v.code for v in voucher_list])
mail(
outgoing_mail = mail(
r['email'],
subject,
LazyI18nString(message),
@@ -60,8 +60,8 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
data={
'recipient': r['email'],
'name': r.get('name'),
'subject': subject,
'message': message,
'subject': outgoing_mail.subject,
'message': outgoing_mail.body_plain,
},
save=False
))

View File

@@ -363,7 +363,7 @@ class EmailAddressShredder(BaseDataShredder):
le.save(update_fields=['data', 'shredded'])
else:
shred_log_fields(le, banlist=[
'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email'
'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email', 'bcc', 'cc',
])

View File

@@ -12,6 +12,9 @@
<meta charset="utf-8">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
{% block custom_header %}{% endblock %}
{% if css_theme %}
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
{% endif %}
</head>
<body>
<div class="container">

View File

@@ -641,6 +641,7 @@ class TeamMembershipLogEntryType(LogEntryType):
'pretix.team.member.added': _('{user} has been added to the team.'),
'pretix.team.member.removed': _('{user} has been removed from the team.'),
'pretix.team.invite.created': _('{user} has been invited to the team.'),
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
})
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load static %}
{% block title %}{% trans "Events" %}{% endblock %}
{% block content %}
<h1>{% trans "Events" %}</h1>
@@ -74,6 +75,7 @@
<a href="?{% url_replace request 'ordering' 'organizer' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Sales channels" %}</th>
<th>
{% trans "Start date" %}
<a href="?{% url_replace request 'ordering' '-date_from' %}"><i class="fa fa-caret-down"></i></a>
@@ -108,6 +110,21 @@
{% endfor %}
</td>
{% if not hide_orga %}<td>{{ e.organizer }}</td>{% endif %}
<td>
{% for c in e.organizer.sales_channels.all %}
{% if e.all_sales_channels or c in e.limit_sales_channels.all %}
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% else %}
<span class="fa fa-fw"></span>
{% endif %}
{% endfor %}
</td>
<td class="event-date-col">
{% if e.has_subevents %}
<span class="fa fa-fw- fa-calendar"></span>

View File

@@ -3,16 +3,26 @@
<dl class="dl-horizontal">
<dt>{% trans "Gift card code" %}</dt>
<dd>
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
{{ gc.secret }}
</a>
{% if gc.issuer != request.organizer %}
<span class="text-muted">
<br>
<span class="fa fa-group"></span> {{ gc.issuer }}
</span>
{% if gc %}
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
{{ gc.secret }}
</a>
{% if gc.issuer.slug != request.organizer %}
<span class="text-muted">
<br>
<span class="fa fa-group"></span> {{ gc.issuer }}
</span>
{% endif %}
{% elif gift_card_secret %}
{{ gift_card_secret }}
{% endif %}
</dd>
<dt>{% trans "Issuer" %}</dt>
<dd>{{ gc.issuer }}</dd>
{% if gc %}
<dt>{% trans "Issuer" %}</dt>
<dd>{{ gc.issuer }}</dd>
{% endif %}
{% if error %}
<dt>{% trans "Error" %}</dt>
<dd>{{ error }}</dd>
{% endif %}
</dl>

View File

@@ -24,7 +24,9 @@
{% if log.display %}
<br/><span class="fa fa-fw fa-comment-o"></span> {{ log.display }}
{% endif %}
{% if log.parsed_data.recipient %}
{% if log.parsed_data.to %}
<br/><span class="fa fa-fw fa-envelope-o"></span> {{ log.parsed_data.to|join:", " }}
{% elif log.parsed_data.recipient %} {# legacy #}
<br/><span class="fa fa-fw fa-envelope-o"></span> {{ log.parsed_data.recipient }}
{% endif %}
</p>

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% block inner %}
<h1>
{% blocktrans with name=request.organizer.name %}Organizer: {{ name }}{% endblocktrans %}
@@ -62,6 +63,7 @@
<thead>
<tr>
<th>{% trans "Event name" %}</th>
<th>{% trans "Sales channels" %}</th>
<th>
{% trans "Start date" %}
/
@@ -77,10 +79,30 @@
<td>
<strong><a
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
<br><small>{{ e.slug }}</small>
{% for k, v in e.meta_data.items %}
{% if v %}
<small class="text-muted">&middot; {{ k }}: {{ v }}</small>
<br>
<small>
{{ e.slug }}
</small>
<small class="text-muted">
{% for k, v in e.meta_data.items %}
{% if v %}
&middot; {{ k }}: {{ v }}
{% endif %}
{% endfor %}
</small>
</td>
<td>
{% for c in sales_channels %}
{% if e.all_sales_channels or c in e.limit_sales_channels.all %}
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% else %}
<span class="fa fa-fw"></span>
{% endif %}
{% endfor %}
</td>

View File

@@ -264,12 +264,17 @@
The paper size will match the PDF.
{% endblocktrans %}
</p>
<p>
<p class="text-center">
<span class="btn btn-default fileinput-button background-button btn-block">
<i class="fa fa-upload"></i>
<span>{% trans "Upload PDF as background" %}</span>
<input id="fileupload" type="file" name="background" accept="application/pdf">
</span>
<small class="text-muted">
{% blocktrans trimmed with size=maxfilesize|filesizeformat %}
max. {{ size }}, smaller is better
{% endblocktrans %}
</small>
</p>
<p class="text-center">
<a class="btn btn-link background-download-button" href="{{ pdf }}" target="_blank">

View File

@@ -743,12 +743,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
resp = HttpResponse(fcontent, content_type=ftype)
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname)
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
return resp

View File

@@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data)
resp['Content-Type'] = mime
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
resp._csp_ignore = True
return resp

View File

@@ -67,7 +67,12 @@ class EventList(PaginationMixin, ListView):
def get_queryset(self):
qs = self.request.user.get_events_with_any_permission(self.request).prefetch_related(
'organizer', '_settings_objects', 'organizer___settings_objects', 'organizer__meta_properties',
'organizer',
'organizer__sales_channels',
'_settings_objects',
'organizer___settings_objects',
'organizer__meta_properties',
'limit_sales_channels',
Prefetch(
'meta_values',
EventMetaValue.objects.select_related('property'),

View File

@@ -710,22 +710,26 @@ class OrderDownload(AsyncAction, OrderView):
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
),
content_type=value.type
)
return resp
elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
),
content_type=value.type
)
return resp
else:
return redirect(self.get_self_url())
@@ -1831,15 +1835,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
return redirect(self.get_order_url())
try:
resp = FileResponse(self.invoice.file.file, content_type='application/pdf')
return FileResponse(
self.invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError:
invoice_pdf_task.apply(args=(self.invoice.pk,))
return self.get(request, *args, **kwargs)
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number))
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
return resp
class OrderExtend(OrderView):
permission = 'can_change_orders'
@@ -2421,9 +2425,9 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
with language(order.locale, self.request.event.settings.region):
email_context = get_email_context(event=order.event, order=order)
email_template = LazyI18nString(form.cleaned_data['message'])
email_subject = format_map(str(form.cleaned_data['subject']), email_context)
email_content = render_mail(email_template, email_context)
if self.request.POST.get('action') == 'preview':
email_subject = format_map(form.cleaned_data['subject'], email_context)
email_content = render_mail(email_template, email_context)
self.preview_output = {
'subject': mark_safe(_('Subject: {subject}').format(
subject=prefix_subject(order.event, escape(email_subject), highlight=True)
@@ -2485,9 +2489,9 @@ class OrderPositionSendMail(OrderSendMail):
with language(position.order.locale, self.request.event.settings.region):
email_context = get_email_context(event=position.order.event, order=position.order, position=position)
email_template = LazyI18nString(form.cleaned_data['message'])
email_subject = format_map(str(form.cleaned_data['subject']), email_context)
email_content = render_mail(email_template, email_context)
if self.request.POST.get('action') == 'preview':
email_subject = format_map(str(form.cleaned_data['subject']), email_context)
email_content = render_mail(email_template, email_context)
self.preview_output = {
'subject': mark_safe(_('Subject: {subject}').format(
subject=prefix_subject(position.order.event, escape(email_subject), highlight=True))

View File

@@ -207,6 +207,7 @@ class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
'organizer').prefetch_related(
'organizer', '_settings_objects', 'organizer___settings_objects',
'organizer__meta_properties',
'limit_sales_channels',
Prefetch(
'meta_values',
EventMetaValue.objects.select_related('property'),
@@ -237,6 +238,7 @@ class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
self.filter_form['meta_{}'.format(p.name)] for p in
self.organizer.meta_properties.filter(filter_allowed=True)
]
ctx['sales_channels'] = self.request.organizer.sales_channels.all()
return ctx

View File

@@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
return resp
elif "data" in request.POST:
if cf:
@@ -292,6 +287,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
ctx['layout'] = json.dumps(self.get_current_layout())
ctx['title'] = self.title
ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales]
ctx['maxfilesize'] = self.maxfilesize
return ctx
@@ -308,6 +304,5 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp

View File

@@ -131,7 +131,7 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
elif v.quota:
prod = _('Any product in quota "{quota}"').format(quota=str(v.quota.name))
else:
prod = _('Any product')
prod = ""
row = [
v.code,
v.valid_until.isoformat() if v.valid_until else "",

View File

@@ -7,7 +7,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-24 11:50+0000\n"
"PO-Revision-Date: 2026-03-03 20:00+0000\n"
"PO-Revision-Date: 2026-03-14 22:00+0000\n"
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/>"
"\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.1\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -27734,7 +27734,7 @@ msgstr "Toon accountgeschiedenis"
#: pretix/control/templates/pretixcontrol/user/staff_session_edit.html:4
msgid "Staff session"
msgstr "Personeelssessie"
msgstr "Beheerderssessie"
#: pretix/control/templates/pretixcontrol/user/staff_session_edit.html:6
msgid "Session notes"

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-24 11:50+0000\n"
"PO-Revision-Date: 2026-03-02 10:00+0000\n"
"PO-Revision-Date: 2026-03-14 22:00+0000\n"
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
"Language-Team: Dutch (informal) <https://translate.pretix.eu/projects/pretix/"
"pretix/nl_Informal/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.1\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -27796,7 +27796,7 @@ msgstr "Toon accountgeschiedenis"
#: pretix/control/templates/pretixcontrol/user/staff_session_edit.html:4
msgid "Staff session"
msgstr "Personeelssessie"
msgstr "Beheerderssessie"
#: pretix/control/templates/pretixcontrol/user/staff_session_edit.html:6
msgid "Session notes"

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-24 11:50+0000\n"
"PO-Revision-Date: 2025-01-04 01:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"PO-Revision-Date: 2026-03-11 00:00+0000\n"
"Last-Translator: Demir Kaya <demir@indieturk.org>\n"
"Language-Team: Turkish <https://translate.pretix.eu/projects/pretix/pretix/"
"tr/>\n"
"Language: tr\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.9.2\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -37,7 +37,7 @@ msgstr "Arapça"
#: pretix/_base_settings.py:91
msgid "Basque"
msgstr ""
msgstr "Baskça"
#: pretix/_base_settings.py:92
msgid "Catalan"
@@ -57,7 +57,7 @@ msgstr "Çekce"
#: pretix/_base_settings.py:96
msgid "Croatian"
msgstr ""
msgstr "Hırvatça"
#: pretix/_base_settings.py:97
msgid "Danish"
@@ -89,7 +89,7 @@ msgstr "Yunanca"
#: pretix/_base_settings.py:104
msgid "Hebrew"
msgstr ""
msgstr "İbranice"
#: pretix/_base_settings.py:105
msgid "Indonesian"
@@ -101,7 +101,7 @@ msgstr "İtalyanca"
#: pretix/_base_settings.py:107
msgid "Japanese"
msgstr ""
msgstr "Japonca"
#: pretix/_base_settings.py:108
msgid "Latvian"
@@ -109,7 +109,7 @@ msgstr "Letonca"
#: pretix/_base_settings.py:109
msgid "Norwegian Bokmål"
msgstr ""
msgstr "Norveççe"
#: pretix/_base_settings.py:110
msgid "Polish"
@@ -145,7 +145,7 @@ msgstr "İspanyolca"
#: pretix/_base_settings.py:118
msgid "Spanish (Latin America)"
msgstr ""
msgstr "İspanyolca (Latin Amerikan)"
#: pretix/_base_settings.py:119
msgid "Turkish"
@@ -203,10 +203,8 @@ msgid "Client secret"
msgstr "Müşteri sırrı"
#: pretix/api/models.py:116
#, fuzzy
#| msgid "Enable"
msgid "Enable webhook"
msgstr "Etkinleştirme"
msgstr "Webhook etkinleştir"
#: pretix/api/models.py:117
#: pretix/control/templates/pretixcontrol/organizers/webhooks.html:36
@@ -277,10 +275,9 @@ msgid "Unknown plugin: '{name}'."
msgstr "Bilinmeyen eklenti: '{name}'."
#: pretix/api/serializers/event.py:286 pretix/api/serializers/organizer.py:88
#, fuzzy, python-brace-format
#| msgid "Unknown plugin: '{name}'."
#, python-brace-format
msgid "Restricted plugin: '{name}'."
msgstr "Bilinmeyen eklenti: '{name}'."
msgstr "sınırlandırılmış eklenti: '{name}'."
#: pretix/api/serializers/item.py:87 pretix/api/serializers/item.py:149
#: pretix/api/serializers/item.py:405
@@ -612,10 +609,8 @@ msgid ""
msgstr ""
#: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Shop not live"
msgid "Shop taken live"
msgstr "Mağaza kapalı"
msgstr "Mağaza çevrimdışı"
#: pretix/api/webhooks.py:417
#, fuzzy
@@ -624,16 +619,12 @@ msgid "Shop taken offline"
msgstr "Mağaza çevrimdışı duruma getirildi."
#: pretix/api/webhooks.py:421
#, fuzzy
#| msgid "The order has been created."
msgid "Test-Mode of shop has been activated"
msgstr "Sipariş oluşturuldu."
msgstr "Mağazanın Test-Modu aktive oldu"
#: pretix/api/webhooks.py:425
#, fuzzy
#| msgid "The order has been created."
msgid "Test-Mode of shop has been deactivated"
msgstr "Sipariş oluşturuldu."
msgstr "Mağazanın Test-Modu deaktif oldu"
#: pretix/api/webhooks.py:429
#, fuzzy
@@ -660,10 +651,8 @@ msgid "Waiting list entry received voucher"
msgstr "Liste girdileri bekleniyor"
#: pretix/api/webhooks.py:445
#, fuzzy
#| msgid "Voucher code"
msgid "Voucher added"
msgstr "Kupon kodu"
msgstr "Kupon kodu eklendi"
#: pretix/api/webhooks.py:449
#, fuzzy

View File

@@ -38,7 +38,7 @@ var pretixpaypal = {
credit: gettext('PayPal Credit'),
card: gettext('Credit Card'),
paylater: gettext('PayPal Pay Later'),
ideal: gettext('iDEAL'),
ideal: gettext('iDEAL | Wero'),
sepa: gettext('SEPA Direct Debit'),
bancontact: gettext('Bancontact'),
giropay: gettext('giropay'),

View File

@@ -38,13 +38,10 @@ from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, Event, InvoiceAddress, Order, User,
)
from pretix.base.models import Checkin, Event, InvoiceAddress, Order, User
from pretix.base.services.mail import mail
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
from pretix.helpers.format import format_map
def _chunks(lst, n):
@@ -64,7 +61,6 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
user = User.objects.get(pk=user) if user else None
subject = LazyI18nString(subject)
message = LazyI18nString(message)
attachments_for_log = [cf.filename for cf in CachedFile.objects.filter(pk__in=attachments)] if attachments else []
def _send_to_order(o):
send_to_order = recipients in ('both', 'orders')
@@ -122,7 +118,7 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
with language(o.locale, event.settings.region):
email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p)
mail(
outgoing_mail = mail(
p.attendee_email,
subject,
message,
@@ -135,25 +131,17 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
attach_ical=attach_ical,
attach_cached_files=attachments
)
o.log_action(
'pretix.plugins.sendmail.order.email.sent.attendee',
user=user,
data={
'position': p.positionid,
'subject': format_map(subject.localize(o.locale), email_context),
'message': format_map(message.localize(o.locale), email_context),
'recipient': p.attendee_email,
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': [],
'attach_cached_files': attachments_for_log,
}
)
if outgoing_mail:
o.log_action(
'pretix.plugins.sendmail.order.email.sent.attendee',
user=user,
data=outgoing_mail.log_data(),
)
if send_to_order and o.email:
with language(o.locale, event.settings.region):
email_context = get_email_context(event=event, order=o, invoice_address=ia)
mail(
outgoing_mail = mail(
o.email,
subject,
message,
@@ -165,19 +153,12 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
attach_ical=attach_ical,
attach_cached_files=attachments,
)
o.log_action(
'pretix.plugins.sendmail.order.email.sent',
user=user,
data={
'subject': format_map(subject.localize(o.locale), email_context),
'message': format_map(message.localize(o.locale), email_context),
'recipient': o.email,
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': [],
'attach_cached_files': attachments_for_log,
}
)
if outgoing_mail:
o.log_action(
'pretix.plugins.sendmail.order.email.sent',
user=user,
data=outgoing_mail.log_data(),
)
for chunk in _chunks(objects, 1000):
orders = Order.objects.filter(pk__in=chunk, event=event)

View File

@@ -92,70 +92,89 @@ logger = logging.getLogger('pretix.plugins.stripe')
# State of the payment methods
#
# Source: https://stripe.com/docs/payments/payment-methods/overview
# Last Update: 2023-12-20
# Last Update: 2026-06-12
#
# pretix Staff: Do not forget to enable/"On by default" newly added payment methods in
# Stripe's managed payment methods configuration for the Stripe/pretix.eu connect platform.
#
# The categorization and order of payment methods is based on the list of the managed payment method configration
# in the Stripe Dashboard.
# Cards
# - Credit and Debit Cards: ✓
# - Apple, Google Pay: ✓
# * Cartes Bancaires: ✓
# * Korean cards: ✓
# * Japan installments: ✗
# * JCB: ✓
# * Meses sin intereses: ✗
#
# Bank debits
# - ACH Debit: ✗
# - Canadian PADs: ✗
# - BACS Direct Debit: ✗
# - SEPA Direct Debit: ✓
# - BECS Direct Debit: ✗
# Wallets
# - Apple: ✓ (Cards)
# - Google Pay: ✓ (Cards)
# - Link: ✓ (PaymentRequestButton/Cards)
# - Alipay: ✓
# - Stablecoins and Crypto: ✗
# - Kakao Pay: ✗
# - Naver Pay: ✗
# - MB Way: ✗
# - Satis Pay: ✗
# - WeChat Pay: ✓
# - PAYCO: ✗
# - PayPal: ✓ (No settings UI yet; incompatible with Connect+Direct Charges)
# - Samsung pay: ✗
# - MobilePay: ✓
# - Revolut Pay: ✓
# - Amazon Pay: ✗
# - PayPay: ✗
# - GrabPay: ✗
# - Cash App Pay: ✗
# - Secure Remote Commerce: ✗
#
# Bank redirects
# - Bancontact: ✓
# - BLIK: ✗
# - EPS: ✓
# - giropay: (deprecated)
# - iDEAL: ✓
# - P24: ✓
# - Sofort: (deprecated)
# - FPX:
# - PayNow: ✗
# - UPI: ✗
# - Netbanking: ✗
# - Przelewy24: ✓
# - BLIK: ✗
# - Pay By Bank:
# - TWINT: ✓
# - Wero: ✓ (No settings UI yet)
#
# Bank transfers
# - ACH Bank Transfer: ✗
# - SEPA Bank Transfer: ✗
# - UK Bank Transfer: ✗
# - Multibanco: ✗
# - Furikomi (Japan): ✗
# - Mexico Bank Transfer: ✗
# - giropay: ✗ (deprecated)
# - Sofort: ✗ (deprecated)
#
# Buy now, pay later
# - Affirm:
# - Afterpay/Clearpay: ✗
# - Billie:
# - Klarna: ✓
# - Afterpay/Clearpay: ✗
# - Zip: ✗
# - Alma: ✗
# - Affirm: ✓
#
# Bank debits
# - SEPA Direct Debit: ✓
# - ACH Direct Debit: ✗
# - Australian BECS Direct Debit: ✗
# - Canadian pre-authorized debits: ✗
# - BACS Direct Debit: ✗
# - FPX: ✗
# - NZ BECS Direct Debit: ✗
#
# Bank transfers
# - Bank Transfer: ✗
#
# Vouchers
# - Multibanco: ✓
# - Boleto: ✗
# - Konbini: ✗
# - OXXO: ✗
#
# Real-time payments
# - Swish: ✓
# - UPI: ✗
# - Pix: ✗
# - PayTo: ✗
# - PayNow: ✗
# - PromptPay: ✓
# - Pix: ✗
#
# Vouchers
# - Konbini: ✗
# - OXXO: ✗
# - Boleto: ✗
#
# Wallets
# - Apple Pay: ✓ (Cards)
# - Google Pay: ✓ (Cards)
# - Secure Remote Commerce: ✗
# - Link: ✓ (PaymentRequestButton)
# - Cash App Pay: ✗
# - PayPal: ✓ (No settings UI yet)
# - MobilePay: ✓
# - Alipay: ✓
# - WeChat Pay: ✓
# - GrabPay: ✓
class StripeSettingsHolder(BasePaymentProvider):
@@ -1550,7 +1569,7 @@ class StripeGiropay(StripeRedirectWithAccountNamePaymentIntentMethod):
class StripeIdeal(StripeRedirectMethod):
identifier = 'stripe_ideal'
verbose_name = _('iDEAL via Stripe')
public_name = _('iDEAL')
public_name = _('iDEAL | Wero')
method = 'ideal'
explanation = _(
'iDEAL is an online payment method available to customers of Dutch banks. Please keep your online '

View File

@@ -13,7 +13,7 @@
{% csrf_token %}
<div class="panel-group" id="questions_accordion">
{% if invoice_address_asked or event.settings.invoice_name_required %}
{% if invoice_address_asked and not request.GET.generate_invoice == "true" and not event.settings.invoice_reissue_after_modify %}
{% if invoice_address_asked and not request.GET.generate_invoice == "true" and not invoice_generation_selfservice %}
<div class="alert alert-info">
{% blocktrans trimmed %}
Modifying your invoice address will not automatically generate a new invoice.

View File

@@ -20,6 +20,7 @@
{% bootstrap_form_errors timemachine_form "all" %}
<p>{% trans "Test your shop as if it were a different date and time." %}</p>
<p>{% trans "Please note that the changed time is not taken into account for aspects of the shop that affect quotas, such as the validity period of carts and vouchers." %}</p>
<div class="row">
<div class="col-md-6">
@@ -44,4 +45,4 @@
<div class="clear"></div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -841,9 +841,13 @@ class AnswerDownload(EventViewMixin, View):
return Http404()
ftype, _ = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format(
filename = '{}-cart-{}'.format(
self.request.event.slug.upper(),
os.path.basename(answer.file.name).split('.', 1)[1]
).encode("ascii", "ignore")
resp = FileResponse(
answer.file,
filename=filename,
content_type=ftype or 'application/binary'
)
return resp

View File

@@ -909,6 +909,21 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(
**kwargs,
)
ctx['invoice_generation_selfservice'] = (
self.request.event.settings.invoice_reissue_after_modify or
(
can_generate_invoice(self.request.event, self.order, ignore_payments=True) and
not self.order.invoices.exists()
)
)
return ctx
def dispatch(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
@@ -1205,30 +1220,25 @@ class OrderDownloadMixin:
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
if self.order_position.subevent:
# Subevent date in filename improves accessibility e.g. for screen reader users
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.order_position.subevent.date_from.strftime('%Y_%m_%d'),
self.output.identifier, value.extension
)
else:
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
)
return resp
name_parts = (
self.request.event.slug.upper(),
self.order.code,
str(self.order_position.positionid),
self.order_position.subevent.date_from.strftime('%Y_%m_%d') if self.order_position.subevent else None,
self.output.identifier
)
filename = "-".join(filter(None, name_parts)) + value.extension
return FileResponse(value.file.file, filename=filename, content_type=value.type)
elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename="-".join(self.request.event.slug.upper(), self.order.code, self.output.identifier) + value.extension,
content_type=value.type
)
return resp
else:
return redirect(self.get_self_url())
@@ -1368,13 +1378,14 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
return redirect(self.get_order_url())
try:
resp = FileResponse(invoice.file.file, content_type='application/pdf')
return FileResponse(
invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError:
invoice_pdf_task.apply(args=(invoice.pk,))
return self.get(request, *args, **kwargs)
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number))
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
return resp
class OrderChangeMixin:

View File

@@ -173,8 +173,8 @@ $(function () {
if (!dependents.transmission_peppol_participant_id.val()) {
const fill_peppol_id = function () {
const vatId = dependents.vat_id.val();
if (vatId && vatId.startsWith("BE") && dependents.transmission_type.val() === "peppol" && autofill_peppol_id) {
dependents.transmission_peppol_participant_id.val("0201:" + vatId.substring(2))
if (vatId && vatId.startsWith("BE") && dependents.transmission_type.val() === "peppol") {
dependents.transmission_peppol_participant_id.val("0208:" + vatId.substring(2).replaceAll(".", ""))
}
}
dependents.vat_id.add(dependents.transmission_type).on("change", fill_peppol_id);

View File

@@ -35,8 +35,8 @@ from django_scopes import scopes_disabled
from tests.const import SAMPLE_PNG
from pretix.base.models import (
InvoiceAddress, Item, Order, OrderPosition, Organizer, Question,
SeatingPlan,
GiftCard, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
Organizer, Question, SeatingPlan,
)
from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer
@@ -3371,3 +3371,251 @@ def test_order_create_rounding_default_pretixpos_fallback(device, device_client,
assert resp.data["total"] == "500.00"
assert resp.data["positions"][0]["price"] == "100.00"
assert resp.data["positions"][-1]["price"] == "100.00"
@pytest.mark.parametrize(
"order_status,status_code",
[
(
Order.STATUS_PENDING, 201
),
(
Order.STATUS_PAID, 400
),
],
)
@pytest.mark.django_db
def test_order_create_use_gift_cards_only_pending(token_client, organizer, event, item, quota, question, order_status, status_code):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
gc = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc.transactions.create(value=Decimal("100.00"), acceptor=organizer).save()
res['status'] = order_status
del res['payment_provider']
res['use_gift_cards'] = [gc.secret]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == status_code
if status_code != 201:
assert resp.data == {'use_gift_cards': ['The attribute use_gift_cards is only supported for orders that are created as pending']}
@pytest.mark.django_db
@pytest.mark.parametrize(
"send_mail,mail_amount",
[
(
False, 0
),
(
True, 2
),
],
)
def test_order_create_use_gift_card(token_client, organizer, event, item, quota, question, send_mail, mail_amount):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
if send_mail:
res['send_email'] = True
gc = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc.transactions.create(value=Decimal("100.00"), acceptor=organizer).save()
del res['payment_provider']
res['use_gift_cards'] = [gc.secret]
djmail.outbox = []
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
assert o.status == Order.STATUS_PAID
assert gc.transactions.count() == 2
assert -gc.transactions.last().value == o.total
assert len(djmail.outbox) == mail_amount
@pytest.mark.django_db
def test_order_create_use_multiple_gift_cards(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
del res['payment_provider']
gc_one_eur = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_one_eur.transactions.create(value=Decimal("1.00"), acceptor=organizer).save()
gc_empty = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_wrong_currency = GiftCard.objects.create(issuer=organizer, currency='USD')
gc_wrong_currency.transactions.create(value=Decimal("100.00"), acceptor=organizer).save()
gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_enough_eur.transactions.create(value=Decimal("100.00"), acceptor=organizer).save()
res['use_gift_cards'] = [gc_one_eur.secret, gc_empty.secret, gc_wrong_currency.secret, gc_enough_eur.secret]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
# order has a payment entry per giftcard
assert o.status == Order.STATUS_PAID
assert o.payments.count() == 4
assert gc_one_eur.transactions.count() == 2 # +1€ charge and -1€ payment
assert o.payments.all()[0].state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert Decimal(-1.00) == gc_one_eur.transactions.last().value
assert gc_empty.transactions.count() == 0 # no charge and no payment transaction
assert o.payments.all()[1].state == OrderPayment.PAYMENT_STATE_FAILED
assert gc_wrong_currency.transactions.count() == 1 # charge transaction
assert o.payments.all()[2].state == OrderPayment.PAYMENT_STATE_FAILED
assert gc_enough_eur.transactions.count() == 2 # +100€ charge and -remainder € payment
assert o.payments.all()[3].state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert -(o.total - Decimal(1.00)) == gc_enough_eur.transactions.last().value
@pytest.mark.django_db
def test_order_create_use_gift_card_exclusive_with_payment_provider(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
gc_value = Decimal("1.00")
gc = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc.transactions.create(value=gc_value, acceptor=organizer).save()
res['use_gift_cards'] = [gc.secret]
res_with_payment_provider = copy.deepcopy(res)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res_with_payment_provider
)
assert resp.status_code == 400
assert resp.json() == {"use_gift_cards": ["The attribute use_gift_cards is not compatible with payment_provider or payment_info"]}
res_with_payment_info = copy.deepcopy(res)
res_with_payment_info['payment_info'] = {"a": "b"}
del res_with_payment_info['payment_provider']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res_with_payment_info
)
assert resp.status_code == 400
assert resp.json() == {"use_gift_cards": ["The attribute use_gift_cards is not compatible with payment_provider or payment_info"]}
@pytest.mark.django_db
def test_order_create_use_gift_card_repeated(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
del res['payment_provider']
gc_one_eur = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_one_eur.transactions.create(value=Decimal("1.00"), acceptor=organizer).save()
gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_enough_eur.transactions.create(value=Decimal("100.00"), acceptor=organizer).save()
res['use_gift_cards'] = [gc_one_eur.secret, gc_one_eur.secret, gc_enough_eur.secret]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.json() == {'use_gift_cards': ['Multiple copies of the same gift card secret are not allowed']}
@pytest.mark.django_db
def test_order_create_use_gift_card_invalid_secret(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
res['api_meta'] = {
'test': 1
}
del res['payment_provider']
gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR')
gc_enough_eur.transactions.create(value=Decimal("100.00"),
acceptor=organizer).save()
res['use_gift_cards'] = ["INVALID", gc_enough_eur.secret]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
assert o.status == Order.STATUS_PAID
assert o.payments.count() == 2
assert o.payments.all()[0].state == OrderPayment.PAYMENT_STATE_FAILED
assert o.payments.all()[1].state == OrderPayment.PAYMENT_STATE_CONFIRMED