Compare commits

..
Author SHA1 Message Date
Mira Weller 39df70e654 Fix migration 2026-08-07 21:49:03 +02:00
Mira Weller daa7235c96 Create and delete CheckoutSession throught cart lifetime 2026-08-07 21:32:36 +02:00
Mira Weller cbc9231ab9 Add CheckoutSession model 2026-08-07 21:32:36 +02:00
Raphael MichelandGitHub d08216d8c5 API: Allow to simulate check-ins (#6360)
* API: Allow to simulate check-ins

* Add missing file
2026-08-07 18:59:01 +02:00
Lukas BockstallerandGitHub 4a28689690 log paypal payment durations (#6461)
* log payment processing durations

* remove log noise

* fix attribute access

* remove debugging import
2026-08-07 16:34:36 +02:00
luelistaandGitHub 958f75b109 Add tests to prevent reintroducing CSP nonces (Z#23240534) (#6409)
As discussed in PR #6387
2026-08-07 14:36:46 +02:00
Richard SchreiberandGitHub 81f58456e5 Fix API-docs example for addon_to on order-change (#6451)
* Fix API-docs example for addon_to on order-change

* Update orders.rst
2026-08-07 11:03:36 +02:00
21 changed files with 273 additions and 121 deletions
+2
View File
@@ -71,6 +71,8 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**:
+2 -2
View File
@@ -2038,7 +2038,7 @@ Manipulating individual positions
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position)
* ``item`` (mandatory)
@@ -2348,7 +2348,7 @@ otherwise, such as splitting an order or changing fees.
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"addon_to": 1,
"attendee_name": "Peter",
}
],
+1
View File
@@ -90,6 +90,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+6
View File
@@ -839,6 +839,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
if simulate:
raise CheckInError(
gettext('You cannot simulate a medium exchange.'),
'error'
)
with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange(
@@ -1066,6 +1071,7 @@ class CheckinRPCRedeemView(views.APIView):
legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
simulate=s.validated_data.get('simulate'),
)
@@ -0,0 +1,63 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0307_devicelastseen"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="checkout_sessions",
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]
+43 -8
View File
@@ -628,14 +628,9 @@ class Order(LockModel, LoggedModel):
def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_")
if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = self.event.settings.get('payment_term_mode')
sales_channel_suffix = ""
mode = self.event.settings.get('payment_term_mode')
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if self.event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
@@ -643,7 +638,7 @@ class Order(LockModel, LoggedModel):
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
elif mode == 'minutes':
exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get('payment_term_minutes', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
@@ -3182,6 +3177,39 @@ class Transaction(models.Model):
return self.tax_value_includes_rounding_correction * self.count
class CheckoutSession(models.Model):
"""
A checkout session optionally bundles cart positions with additional information. This is historically
not required in pretix and currently only used in the Storefront API.
"""
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="checkout_sessions",
on_delete=models.CASCADE,
)
cart_id = models.CharField(
max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)"),
)
created = models.DateTimeField(
verbose_name=_("Date"),
auto_now_add=True,
)
customer = models.ForeignKey(
Customer,
related_name='checkout_sessions',
null=True, blank=True,
on_delete=models.SET_NULL,
)
sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.CASCADE,
)
testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict)
class CartPosition(AbstractPosition):
"""
A cart position is similar to an order line, except that it is not
@@ -3386,6 +3414,13 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
checkout_session = models.OneToOneField(
CheckoutSession,
null=True,
blank=True,
related_name='invoice_address',
on_delete=models.CASCADE
)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey(
Customer,
+12 -1
View File
@@ -61,7 +61,7 @@ from pretix.base.models import (
Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import CheckoutSession, OrderFee
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
@@ -472,6 +472,16 @@ class CartManager:
if term_last < time_machine_now(self.real_now_dt):
raise CartError(error_messages['payment_ended'])
def _ensure_checkout_session(self):
CheckoutSession.objects.get_or_create(
event=self.event,
cart_id=self.cart_id,
defaults={
"sales_channel": self._sales_channel,
"testmode": self.event.testmode,
},
)
def _extend_expiry_of_valid_existing_positions(self):
# real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small
# delta to reduce risk of extending already expired CartPositions.
@@ -1559,6 +1569,7 @@ class CartManager:
def commit(self):
self._check_presale_dates()
self._ensure_checkout_session()
self._check_max_cart_size()
err = self._delete_out_of_timeframe()
+4 -3
View File
@@ -40,7 +40,7 @@ import dateutil
import dateutil.parser
from dateutil.tz import datetime_exists
from django.core.files import File
from django.db import IntegrityError, transaction
from django.db import IntegrityError
from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,6 +59,7 @@ from pretix.base.models import (
)
from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF
from pretix.helpers.database import conditional_atomic
from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import (
@@ -1043,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate:
_save_answers(op, answers, given_answers)
with transaction.atomic():
with conditional_atomic(not simulate):
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
opqs = OrderPosition.all.select_related("order", "item")
if type != Checkin.TYPE_EXIT:
if type != Checkin.TYPE_EXIT and not simulate:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
+5
View File
@@ -33,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task
@@ -43,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
):
cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()
+9 -5
View File
@@ -73,7 +73,7 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
BlockedTicketSecret, CheckoutSession, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
)
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
@@ -1030,7 +1030,8 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None,
cart_id: str=None):
payments = []
try:
@@ -1113,6 +1114,8 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
if meta_info:
for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg})
if cart_id:
CheckoutSession.objects.filter(event=event, cart_id=cart_id).delete()
order_placed.send(event, order=order, bulk=False)
return order, payments
@@ -1160,7 +1163,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None, cart_id: str=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
@@ -1267,6 +1270,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
valid_if_pending=valid_if_pending,
api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode,
cart_id=cart_id,
)
try:
@@ -3169,12 +3173,12 @@ class OrderChangeManager:
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None,
api_meta: dict=None):
api_meta: dict=None, cart_id: str=None):
with language(locale), time_machine_now_assigned(override_now_dt):
try:
try:
return _perform_order(event, payments, positions, email, locale, address, meta_info,
sales_channel, shown_total, customer, api_meta)
sales_channel, shown_total, customer, api_meta, cart_id=cart_id)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
+6 -6
View File
@@ -979,12 +979,12 @@ DEFAULTS = {
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'write_permission': 'event.settings.payment:write',
'form_kwargs': lambda suffix="", parent=0: dict(
'form_kwargs': dict(
label=_('Payment term in days'),
widget=forms.NumberInput(
attrs={
'data-display-dependency': f'#id_payment_term_mode{suffix}_{parent}',
'data-required-if': f'#id_payment_term_mode{suffix}_{parent}'
'data-display-dependency': '#id_payment_term_mode_0',
'data-required-if': '#id_payment_term_mode_0'
},
),
help_text=_("The number of days after placing an order the user has to pay to preserve their reservation. If "
@@ -1023,7 +1023,7 @@ DEFAULTS = {
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'write_permission': 'event.settings.payment:write',
'form_kwargs': lambda suffix="", parent=1: dict(
'form_kwargs': dict(
label=_('Payment term in minutes'),
help_text=_("The number of minutes after placing an order the user has to pay to preserve their reservation. "
"Only use this if you exclusively offer real-time payment methods. Please note that for technical reasons, "
@@ -1032,8 +1032,8 @@ DEFAULTS = {
MaxValueValidator(1440)],
widget=forms.NumberInput(
attrs={
'data-display-dependency': f'#id_payment_term_mode{suffix}_{parent}',
'data-required-if': f'#id_payment_term_mode{suffix}_{parent}'
'data-display-dependency': '#id_payment_term_mode_1',
'data-required-if': '#id_payment_term_mode_1'
},
),
),
-44
View File
@@ -856,50 +856,6 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
'tax_rule_payment',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.term_channel_fields = {}
for c in self.obj.organizer.sales_channels.all():
if c.type_instance.payment_restrictions_supported and c.identifier != "web":
# At the moment, it seems sufficient to allow this for the same channel types as other payment settings
# We can always introduce more flags later if needed
suffix = '_' + c.identifier.replace(".", "_")
self.term_channel_fields[c] = [
'payment_term_mode' + suffix,
'payment_term_days' + suffix,
'payment_term_minutes' + suffix,
]
self.fields['payment_term_mode' + suffix] = DEFAULTS['payment_term_mode']['form_class'](
label=_("Payment term"),
widget=forms.RadioSelect,
required=False,
choices=(
('', _("same as above")),
('days', _("different payment term in days")),
('minutes', _("different payment term in minutes"))
),
)
self.fields['payment_term_days' + suffix] = DEFAULTS['payment_term_days']['form_class'](
required=False,
**DEFAULTS['payment_term_days']['form_kwargs'](suffix, 1),
)
self.fields['payment_term_minutes' + suffix] = DEFAULTS['payment_term_minutes']['form_class'](
required=False,
**DEFAULTS['payment_term_minutes']['form_kwargs'](suffix, 2),
)
def clean(self):
data = super().clean()
for c in self.term_channel_fields.keys():
suffix = '_' + c.identifier.replace(".", "_")
mode = self.cleaned_data.get(f'payment_term_mode{suffix}')
if mode == 'days' and self.cleaned_data.get(f'payment_term_days{suffix}') is None:
raise ValidationError({f'payment_term_days{suffix}': _("This field is required.")})
if mode == 'minutes' and self.cleaned_data.get(f'payment_term_minutes{suffix}') is None:
raise ValidationError({f'payment_term_minutes{suffix}': _("This field is required.")})
return data
def clean_payment_term_days(self):
value = self.cleaned_data.get('payment_term_days')
if self.cleaned_data.get('payment_term_mode') == 'days' and value is None:
@@ -2,10 +2,9 @@
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% load getitem %}
{% block inside %}
<h1>{% trans "Payment settings" %}</h1>
<form action="" method="post" class="form-horizontal">
<form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %}
<div class="tabbed-form">
<fieldset>
@@ -72,37 +71,14 @@
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.payment_term_mode layout="control" %}
{% bootstrap_field form.payment_term_days layout="control" %}
{% bootstrap_field form.payment_term_minutes layout="control" %}
{% bootstrap_field form.payment_term_weekdays layout="control" %}
{% bootstrap_field form.payment_term_minutes layout="control" %}
{% bootstrap_field form.payment_term_last layout="control" %}
{% bootstrap_field form.payment_term_expire_automatically layout="control" %}
{% trans "days" context "unit" as days %}
{% bootstrap_field form.payment_term_expire_delay_days layout="control" addon_after=days %}
{% bootstrap_field form.payment_term_accept_late layout="control" %}
{% bootstrap_field form.payment_pending_hidden layout="control" %}
{% for c, fields in form.term_channel_fields.items %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.type_instance.verbose_name }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.type_instance.verbose_name }}"></span>
{% endif %}
{{ c.label }}
</h3>
</div>
<div class="panel-body">
{% for f in fields %}
{% bootstrap_field form|getitem:f layout="control" %}
{% endfor %}
</div>
</div>
{% endfor %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
+9
View File
@@ -288,6 +288,15 @@ def get_deterministic_ordering(model, ordering):
return ordering
@contextlib.contextmanager
def conditional_atomic(do_atomic, **kwargs):
if do_atomic:
with transaction.atomic(**kwargs):
yield
else:
yield
class IgnoreOnSQLiteMixin:
# Mixin to allow defining PostgreSQL-specific indexes that will just not be created
# on SQLite. SQLite is supported for testing only anyways!
+17 -2
View File
@@ -23,7 +23,7 @@ import json
import logging
import urllib.parse
from collections import OrderedDict
from datetime import timedelta
from datetime import datetime, timedelta
from decimal import Decimal
from django import forms
@@ -645,7 +645,7 @@ class PaypalMethod(BasePaymentProvider):
def _execute_payment(self, request: HttpRequest, payment: OrderPayment):
payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('payment is already confirmed; possible return-view/webhook race-condition')
# payment is already confirmed; possible return-view/webhook race-condition
return
try:
@@ -832,6 +832,7 @@ class PaypalMethod(BasePaymentProvider):
payment.info = json.dumps(pp_captured_order.dict())
payment.save(update_fields=['info'])
payment.confirm()
self.log_payment_duration(payment)
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
# Payment has not any captures yet - so it's probably in created status
@@ -841,6 +842,20 @@ class PaypalMethod(BasePaymentProvider):
if 'payment_paypal_oid' in request.session:
del request.session['payment_paypal_oid']
@staticmethod
def log_payment_duration(payment: OrderPayment):
try:
capture = payment.info_data["purchase_units"][0]["payments"]["captures"][0]
create_time: str | None = capture["create_time"]
update_time: str | None = capture["update_time"]
except (KeyError, IndexError, TypeError):
create_time = None
update_time = None
if create_time is not None and update_time is not None:
duration = datetime.fromisoformat(update_time) - datetime.fromisoformat(create_time)
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
+1
View File
@@ -490,6 +490,7 @@ def webhook(request, *args, **kwargs):
payment.info = json.dumps(sale.dict())
payment.save(update_fields=['info'])
payment.confirm()
prov.log_payment_duration(payment)
except Quota.QuotaExceededException:
pass
elif sale['status'] == 'APPROVED':
+1
View File
@@ -1660,6 +1660,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
customer=self.cart_session.get('customer'),
override_now_dt=time_machine_now(default=None),
api_meta=api_meta,
cart_id=get_or_create_cart_id(request),
)
def get_success_message(self, value):
+25
View File
@@ -0,0 +1,25 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
def readonly_db(execute, sql, params, many, context):
if not sql.lower().startswith("select"):
raise Exception(f"Should not write anything to the database, but detected query: {sql}")
return execute(sql, params, many, context)
+40
View File
@@ -25,6 +25,7 @@ from unittest import mock
import pytest
from django.core.files.base import ContentFile
from django.db import connection
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scopes_disabled
@@ -36,6 +37,7 @@ from pretix.api.serializers.item import QuestionSerializer
from pretix.base.models import (
Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium,
)
from pretix.testutils.db import readonly_db
# Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering
# the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take
@@ -1739,3 +1741,41 @@ def test_exchange_create_gift_card(token_client, organizer, clist, event, order,
with scopes_disabled():
rm = ReusableMedium.objects.get(identifier="0412345")
assert rm.linked_giftcard.currency == "EUR"
@pytest.mark.django_db
def test_simulate(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
with connection.execute_wrapper(readonly_db):
resp = _redeem(token_client, organizer, clist, p.secret, {"simulate": True})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert not p.checkins.exists()
@pytest.mark.django_db
def test_simulate_no_exchange(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
with connection.execute_wrapper(readonly_db):
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
"simulate": True,
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'error'
with scopes_disabled():
assert not rm.linked_orderpositions.exists()
-24
View File
@@ -286,30 +286,6 @@ def test_expiry_dst(event):
assert (localex.hour, localex.minute) == (23, 59)
@pytest.mark.django_db
def test_expiry_per_channel(event):
today = now()
event.settings.set('payment_term_mode', 'minutes')
event.settings.set('payment_term_minutes', 30)
event.settings.set('payment_term_mode_baz', 'minutes')
event.settings.set('payment_term_minutes_baz', 15)
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today,
sales_channel=event.organizer.sales_channels.get(identifier="baz"),
payment_requests=[{
"id": "test0",
"provider": "free",
"max_value": None,
"min_value": None,
"multi_use_supported": False,
"info_data": {},
"pprov": FreeOrderProvider(event),
}],
locale='de')[0]
assert (order.expires - today).days == 0
assert (order.expires - today).seconds == 15 * 60
@pytest.mark.django_db
def test_expiring(event):
o1 = Order.objects.create(
+25
View File
@@ -224,3 +224,28 @@ def test_one_view(logged_in_client, url, expected, event, item, item_category, o
)
response = logged_in_client.get(url)
assert response.status_code == expected
# Do not reintroduce any CSP nonces into control responses, as discussed in PR #6387
if response['Content-Type'] != 'application/json':
assert 'script-src' in response['Content-Security-Policy']
assert 'nonce-' not in response['Content-Security-Policy']
@pytest.mark.parametrize('url', [
'/control/login',
'/',
'/{orga}/{event}/',
])
@pytest.mark.django_db
def test_csp_header_unauthenticated(client, url, event):
# Do not reintroduce any CSP nonces into most presale responses, as discussed in PR #6387
with scope(organizer=event.organizer):
url = url.format(
event=event.slug, orga=event.organizer.slug,
)
event.live = True
event.save()
response = client.get(url)
assert response.status_code == 200
assert 'script-src' in response['Content-Security-Policy']
assert 'nonce-' not in response['Content-Security-Policy']