Compare commits

..
Author SHA1 Message Date
Richard Schreiber 104ce39bbb rename v-focus to v-autofocus 2026-08-14 12:21:21 +02:00
Richard Schreiber c259c300bb fix eslint 2026-08-14 11:53:27 +02:00
Kara Engelhardt fd1e20808f Fix autofocus in vue3 widget
autofocus no longer works as the elements are added dynamically
2026-08-13 16:57:54 +02:00
Kara Engelhardt 4f94e1fb66 Fix safari blocking widget opening error url in new tab (Z#23243124) 2026-08-13 16:57:47 +02:00
7fe31634e6 Allow RelativeDate in relation to a moment of order (#6160)
* initial implementation

* factor out _resolve_base_date

* add js to prevent illegal inputs

* fix tests

* Update src/pretix/base/reldate.py

Co-authored-by: Raphael Michel <michel@pretix.eu>

* Apply suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* move js includes of to fragment_js.html

* add type annotations

* moves logic from RelativeDateWrapper into RelativeDate and adds BaseChoice for configuring which models attributes support which relationship

* fix tests

* test upgrade behaviour

* Apply suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* move reldate.js include in correct file

* add OptionAttrsSelect to allow select options with their own attributes per value

* add a little bit of information to the tests for future reference

* rewrite reldate.js use data-attributes

* general cleanup

* add test for order.subevents cases

* use correct choice format

* remove order.subevent variants

* various cleanup

* Apply suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* remove empty docstrings

* add depreciation warning

* change event listener to pretix:bind-forms

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-08-12 15:37:29 +02:00
f25c233e91 Fix performance issues in download reminder (#6393)
* Fix performance issues in download reminder

* Update src/pretix/base/services/orders.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Fixes after review

* Fix check in loop

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-08-10 16:13:54 +02:00
robbi5andGitHub 5cf28f1b81 Fix typo in devicesecurity blocking checkinrpc.annul (#6467) 2026-08-10 15:58:15 +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
4d9dfa88fe Paypal2: handle incomming capture webhooks (Z#23240966) (#6456)
* store the state of the payment regardless of the state

control.html shows the banner that the payment is in review depending on payment.info

* handle capture ressource

* Update src/pretix/plugins/paypal2/views.py

Co-authored-by: Phin Wolkwitz <wolkwitz@pretix.eu>

* add test

* cleanup logic regarding uninteresting resource_type

* store payment.info during _execute_payment asap

---------

Co-authored-by: Phin Wolkwitz <wolkwitz@pretix.eu>
2026-08-06 15:06:20 +02:00
32 changed files with 845 additions and 362 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",
}
],
+3 -3
View File
@@ -115,7 +115,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
@@ -154,7 +154,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
@@ -191,7 +191,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
+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'),
)
+15
View File
@@ -314,3 +314,18 @@ class BusinessBooleanRadio(forms.RadioSelect):
'False': False,
False: False,
}.get(value)
class OptionAttrsSelect(forms.Select):
def __init__(self, *args, option_attrs=None, **kwargs):
super().__init__(*args, **kwargs)
self.option_attrs = option_attrs or {}
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(
name, value, label, selected, index, subindex=subindex, attrs=attrs
)
extra = self.option_attrs.get(str(value))
if extra:
option["attrs"].update(extra)
return option
+373 -155
View File
@@ -19,30 +19,245 @@
# 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/>.
#
import copy
import datetime
import os
import warnings
from collections import namedtuple
from typing import Union
from dataclasses import dataclass
from typing import (
TYPE_CHECKING, Iterable, List, Literal, Optional, Tuple, Union, cast,
)
from zoneinfo import ZoneInfo
from dateutil import parser
from django import forms
from django.core.exceptions import ValidationError
from django.db import models
from django.forms.widgets import Widget
from django.utils.formats import get_format
from django.utils.functional import lazy
from django.utils.functional import Promise, lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
BASE_CHOICES = (
('date_from', _('Event start')),
('date_to', _('Event end')),
('date_admission', _('Event admission')),
('presale_start', _('Presale start')),
('presale_end', _('Presale end')),
)
from pretix.base.forms.widgets import OptionAttrsSelect
RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from'))
if TYPE_CHECKING:
from .models import Event, Order, SubEvent
@dataclass(frozen=True)
class BaseChoice:
base: Literal["event", "order"]
attribute: str
text: Promise
supports_before: bool
supports_after: bool
@property
def key(self) -> str:
key = f"{self.base}__{self.attribute}"
return key
@staticmethod
def find(objects: Iterable["BaseChoice"], key: str) -> "BaseChoice":
if "__" in key:
choice = next((obj for obj in objects if obj.key == key), None)
else:
# fallback for RelativeDateFields stored, before support for bases other than event was added
choice = next((obj for obj in objects if obj.attribute == key and obj.base == "event"), None)
if choice is None:
raise TypeError(f"key {key} must be a valid key in BASE_CHOICES")
return choice
BASE_CHOICES: List[BaseChoice] = [
BaseChoice('event', 'date_from', _('Event start'), True, True),
BaseChoice('event', 'date_to', _('Event end'), True, True),
BaseChoice('event', 'date_admission', _('Event admission'), True, True),
BaseChoice('event', 'presale_start', _('Presale start'), True, True),
BaseChoice('event', 'presale_end', _('Presale end'), True, True),
BaseChoice('order', 'datetime', _('Order creation'), False, True),
BaseChoice('order', 'expires', _('Order expiry'), True, True),
]
EVENT_BASE_CHOICES = [
x for x in BASE_CHOICES if x.base == 'event'
]
ORDER_BASE_CHOICES = [
x for x in BASE_CHOICES if x.base == 'order'
]
@dataclass(frozen=True)
class RelativeDate:
"""
This contains information on a date that is defined in relation to a fixed base point.
This means that the underlying data is a fixed date as the base point and a number of days or a time interval
to calculate the date.
The list of valid base date choices is defined in BASE_CHOICES.
If the base_date_name is not set, the date_from attribute of Event is used.
"""
days: int = 0
minutes: Optional[int] = None
time: Optional[datetime.time] = None
is_after: bool = False
base_date_name: str = 'event__date_from__'
def __post_init__(self) -> None:
if self.is_after and not self._choice.supports_after:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed after the base date"
)
if not self.is_after and not self._choice.supports_before:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed before the base date")
@property
def _choice(self):
return BaseChoice.find(BASE_CHOICES, self.base_date_name)
@property
def key(self):
return self._choice.key
def __eq__(self, o: object) -> bool:
if not isinstance(o, RelativeDate):
return False
return self.to_string() == o.to_string()
def _resolve_date(self, base: "Event | Order | SubEvent") -> Tuple[datetime.datetime, ZoneInfo]:
"""
Resolves the datetime and timezone information of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the relative date
:return:
"""
from .models import Event, Order, SubEvent
choice = self._choice
if choice.base == "order" and isinstance(base, Order):
event = base.event
base_date = getattr(base, choice.attribute)
elif choice.base == "event" and isinstance(base, SubEvent):
event = base.event
base_date = (getattr(base, choice.attribute) or
getattr(base.event, choice.attribute) or
base.date_from)
elif choice.base == "event" and isinstance(base, Event):
event = base
base_date = getattr(base, choice.attribute) or event.date_from
else:
raise TypeError("The base defined by data does not match the passed in base")
tz = ZoneInfo(event.settings.timezone)
return base_date, tz
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
"""
Resolves the effective date of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the date
:return: datetime.date
"""
if self.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
base_date, tz = self._resolve_date(base)
if self.is_after:
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.days)
else:
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.days)
return new_date.date()
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime:
"""
Resolves the effective datetime of this RelativeDate object in relation to the provided reference.
:param base: the reference which should be used to resolve the datetime
:return: datetime.datetime
"""
base_date, tz = self._resolve_date(base)
if self.minutes is not None:
if self.is_after:
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.minutes)
else:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.minutes)
else:
if self.is_after:
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.days)).astimezone(tz)
else:
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.days)).astimezone(tz)
if self.time:
new_date = new_date.replace(
hour=self.time.hour,
minute=self.time.minute,
second=self.time.second
)
new_date = new_date.astimezone(tz)
return new_date
def to_string(self) -> str:
if self.minutes is not None:
return 'RELDATE/minutes/{}/{}/{}'.format( #
self.minutes,
self._choice.key,
'after' if self.is_after else '',
)
return 'RELDATE/{}/{}/{}/{}'.format( #
self.days,
self.time.strftime('%H:%M:%S') if self.time else '-',
self._choice.key,
'after' if self.is_after else '',
)
@classmethod
def from_string(cls, input: str):
if not input.startswith('RELDATE/'):
raise TypeError("Invalid input for RelativeDate.from_string()")
parts = input.split('/')
if parts[1] == 'minutes':
data = RelativeDate(
days=0,
minutes=int(parts[2]),
base_date_name=parts[3],
time=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
else:
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days=int(parts[1] or 0),
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
except ValueError:
data = RelativeDate(
days=0,
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
return data
class RelativeDateWrapper:
@@ -50,130 +265,53 @@ class RelativeDateWrapper:
This contains information on a date that might be relative to an event. This means
that the underlying data is either a fixed date or a number of days and a wall clock
time to calculate the date based on a base point.
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
will be used.
"""
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
self.data = data
def date(self, event) -> datetime.date:
from .models import SubEvent
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
"""
If the RelativeDateWrapper wraps a RelativeDate object:
Resolves the effective date of this object in relation to the provided reference.
If the RelativeDateWrapper wraps an absolute date or datetime:
Returns the wrapped absolute date.
:param base: the reference which should be used to resolve the date in case it is a relative date.
:return: datetime.date
"""
if isinstance(self.data, datetime.datetime):
return self.data.date()
elif isinstance(self.data, datetime.date):
return self.data
else:
if self.data.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
return self.data.date(base)
tz = ZoneInfo(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
getattr(event, self.data.base_date_name)
or getattr(event.event, self.data.base_date_name)
or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.is_after:
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)
else:
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)
return new_date.date()
def datetime(self, event) -> datetime.datetime:
from .models import SubEvent
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime:
"""
If the RelativeDateWrapper wraps a RelativeDate object:
Resolves the effective datetime of this object in relation to the provided reference.
If the RelativeDateWrapper wraps an absolute date or datetime:
Returns the wrapped absolute datetime.
:param base: the reference which should be used to resolve the datetime in case it is a relative datetime.
:return: datetime.datetime
"""
if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data
else:
tz = ZoneInfo(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
getattr(event, self.data.base_date_name)
or getattr(event.event, self.data.base_date_name)
or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.minutes is not None:
if self.data.is_after:
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.data.minutes)
else:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes)
else:
if self.data.is_after:
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)).astimezone(tz)
else:
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)).astimezone(tz)
if self.data.time:
new_date = new_date.replace(
hour=self.data.time.hour,
minute=self.data.time.minute,
second=self.data.time.second
)
new_date = new_date.astimezone(tz)
return new_date
return self.data.datetime(base)
def to_string(self) -> str:
if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data.isoformat()
else:
if self.data.minutes is not None:
return 'RELDATE/minutes/{}/{}/{}'.format( #
self.data.minutes,
self.data.base_date_name,
'after' if self.data.is_after else '',
)
return 'RELDATE/{}/{}/{}/{}'.format( #
self.data.days,
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
self.data.base_date_name,
'after' if self.data.is_after else '',
)
return self.data.to_string()
@classmethod
def from_string(cls, input: str):
if input.startswith('RELDATE/'):
parts = input.split('/')
if parts[1] == 'minutes':
data = RelativeDate(
days=0,
minutes=int(parts[2]),
base_date_name=parts[3],
time=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
else:
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days=int(parts[1] or 0),
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
except ValueError:
data = RelativeDate(
days=0,
base_date_name=parts[3],
time=time,
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
data = RelativeDate.from_string(input)
else:
data = parser.parse(input)
return RelativeDateWrapper(data)
@@ -187,7 +325,6 @@ BEFORE_AFTER_CHOICE = (
('after', _('after')),
)
reldatetimeparts = namedtuple('reldatetimeparts', (
"status", # 0
"absolute", # 1
@@ -202,6 +339,14 @@ reldatetimeparts = namedtuple('reldatetimeparts', (
reldatetimeparts.indizes = reldatetimeparts(*range(9))
def _get_choices(base_choices: List[BaseChoice]) -> List[Tuple[str, Promise]]:
return [(c.key, c.text) for c in base_choices]
def _get_choice_validation_obj(choices: List[BaseChoice]):
return {c.key: {"data-supports-before": c.supports_before, "data-supports-after": c.supports_after} for c in choices}
class RelativeDateTimeWidget(forms.MultiWidget):
template_name = 'pretixbase/forms/widgets/reldatetime.html'
parts = reldatetimeparts
@@ -209,6 +354,7 @@ class RelativeDateTimeWidget(forms.MultiWidget):
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
base_choices = kwargs.pop('base_choices')
choices = _get_choices(base_choices)
def placeholder_datetime_format():
df = get_format('DATETIME_INPUT_FORMATS')[0]
@@ -220,21 +366,29 @@ class RelativeDateTimeWidget(forms.MultiWidget):
tf = get_format('TIME_INPUT_FORMATS')[0]
return datetime.time(8, 30, 0).strftime(tf)
widgets = reldatetimeparts(
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldatetimeparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateTimeInput(
attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'}
),
rel_days_number=forms.NumberInput(),
rel_mins_relationto=forms.Select(choices=base_choices),
rel_mins_relationto=OptionAttrsSelect(
attrs={'data-relative-choice': True},
choices=choices,
option_attrs=_get_choice_validation_obj(base_choices)
),
rel_days_timeofday=forms.TimeInput(
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
),
rel_mins_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=base_choices),
rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
)
rel_days_relationto=OptionAttrsSelect(
attrs={'data-relative-choice': True},
choices=choices,
option_attrs=_get_choice_validation_obj(base_choices)
),
rel_mins_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
rel_days_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
)))
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
@@ -247,10 +401,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="unset",
absolute=None,
rel_days_number=1,
rel_mins_relationto="date_from",
rel_mins_relationto="event__date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="date_from",
rel_days_relationto="event__date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
@@ -259,10 +413,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="absolute",
absolute=value.data,
rel_days_number=1,
rel_mins_relationto="date_from",
rel_mins_relationto="event__date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="date_from",
rel_days_relationto="event__date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
@@ -271,10 +425,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative_minutes",
absolute=None,
rel_days_number=None,
rel_mins_relationto=value.data.base_date_name,
rel_mins_relationto=value.data.key,
rel_days_timeofday=None,
rel_mins_number=value.data.minutes,
rel_days_relationto=value.data.base_date_name,
rel_days_relationto=value.data.key,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -282,10 +436,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_mins_relationto=value.data.base_date_name,
rel_mins_relationto=value.data.key,
rel_days_timeofday=value.data.time,
rel_mins_number=0,
rel_days_relationto=value.data.base_date_name,
rel_days_relationto=value.data.key,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -309,17 +463,41 @@ class RelativeDateTimeField(forms.MultiValueField):
('relative', _('Relative date:')),
('relative_minutes', _('Relative time:')),
]
self.relative_to_order = kwargs.pop('relative_to_order', False)
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
if self.relative_to_order:
possible_choices.extend(ORDER_BASE_CHOICES)
if kwargs.get('limit_choices'):
limit = kwargs.pop('limit_choices')
choices = [(k, v) for k, v in BASE_CHOICES if k in limit]
else:
choices = BASE_CHOICES
if any(["__" not in l for l in limit]):
_warn_skips = (str(os.path.dirname(__file__)),)
warnings.warn(
message="Please prefix limit_choices with the base the attributes refer to, for example event__date_from",
category=DeprecationWarning,
skip_file_prefixes=_warn_skips
)
possible_choices = [
c for c in possible_choices if
# new base case as we want limit_choices to be expressed as base__attribute
(c.key in limit) or
# fallback for old event based entries
# if the base is an event, then using only attribute is fine
(c.base == "event" and c.attribute in limit)
]
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
choices = _get_choices(possible_choices)
fields = reldatetimeparts(
status=forms.ChoiceField(
choices=status_choices,
required=True
required=True,
),
absolute=forms.DateTimeField(
required=False
@@ -329,7 +507,7 @@ class RelativeDateTimeField(forms.MultiValueField):
),
rel_mins_relationto=forms.ChoiceField(
choices=choices,
required=False
required=False,
),
rel_days_timeofday=forms.TimeField(
required=False,
@@ -339,7 +517,7 @@ class RelativeDateTimeField(forms.MultiValueField):
),
rel_days_relationto=forms.ChoiceField(
choices=choices,
required=False
required=False,
),
rel_mins_relation=forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE,
@@ -350,8 +528,9 @@ class RelativeDateTimeField(forms.MultiValueField):
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices)
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices)
kwargs.pop('max_length', 0)
kwargs.pop('empty_value', 0)
super().__init__(
@@ -359,21 +538,24 @@ class RelativeDateTimeField(forms.MultiValueField):
)
def set_event(self, event):
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
if self.relative_to_order:
possible_choices.extend(ORDER_BASE_CHOICES)
possible_choices = possible_choices
choices = _get_choices(possible_choices)
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = choices
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = choices
def compress(self, data_list):
if not data_list:
return None
data = reldatetimeparts(*data_list)
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
if data.status == 'unset':
return None
elif data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'relative_minutes':
return RelativeDateWrapper(RelativeDate(
days=0,
@@ -404,6 +586,18 @@ class RelativeDateTimeField(forms.MultiValueField):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative':
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
if data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_('A relative date cannot be expressed as "before" for "{}"'.format(choice.text)))
elif data.status == 'relative' and data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_('A relative date cannot be expressed as "after" for "{}"'.format(choice.text)))
elif data.status == 'relative_minutes':
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
if data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_('A relative time cannot be expressed as "before" for "{}"'.format(choice.text)))
elif data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_('A relative time cannot be expressed as "after" for "{}"'.format(choice.text)))
return super().clean(value)
@@ -424,15 +618,20 @@ class RelativeDateWidget(RelativeDateTimeWidget):
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
widgets = reldateparts(
self.base_choices = kwargs.pop('base_choices')
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldateparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateInput(
attrs={'class': 'datepickerfield'}
),
rel_days_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
)
rel_days_relationto=OptionAttrsSelect(
choices=self.base_choices,
option_attrs=_get_choice_validation_obj(self.base_choices),
attrs={'data-relative-choice': True},
),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE, attrs={'data-relation-choice': True},),
)))
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
def decompress(self, value):
@@ -460,7 +659,7 @@ class RelativeDateWidget(RelativeDateTimeWidget):
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_days_relationto=value.data.base_date_name,
rel_days_relationto=value.data.key,
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -474,6 +673,15 @@ class RelativeDateField(RelativeDateTimeField):
]
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
self.relative_to_order = kwargs.pop('relative_to_order', False)
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
if self.relative_to_order:
possible_choices.extend(ORDER_BASE_CHOICES)
choices = _get_choices(possible_choices)
fields = reldateparts(
status=forms.ChoiceField(
choices=status_choices,
@@ -486,33 +694,37 @@ class RelativeDateField(RelativeDateTimeField):
required=False
),
rel_days_relationto=forms.ChoiceField(
choices=BASE_CHOICES,
required=False
choices=choices,
required=False,
),
rel_days_relation=forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE,
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=possible_choices)
forms.MultiValueField.__init__(
self, fields=fields, require_all_fields=False, *args, **kwargs
)
def set_event(self, event):
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
choices = [
(c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None)
]
if self.relative_to_order:
choices += [(c.key, c.text) for c in ORDER_BASE_CHOICES]
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices
def compress(self, data_list):
if not data_list:
return None
data = reldateparts(*data_list)
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
if data.status == 'unset':
return None
elif data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
else:
return RelativeDateWrapper(RelativeDate(
days=data.rel_days_number,
@@ -525,8 +737,14 @@ class RelativeDateField(RelativeDateTimeField):
data = reldateparts(*value)
if data.status == 'absolute' and not data.absolute:
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
raise ValidationError(self.error_messages['incomplete'])
if data.status == 'relative':
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
if data.rel_days_number is None or not data.rel_days_relationto:
raise ValidationError(self.error_messages['incomplete'])
elif data.rel_days_relation == "before" and not choice.supports_before:
raise ValidationError(_("A relative date cannot be expressed as 'before' for '{}'".format(choice.text)))
elif data.rel_days_relation == "after" and not choice.supports_after:
raise ValidationError(_("A relative date cannot be expressed as 'after' for '{}'".format(choice.text)))
return forms.MultiValueField.clean(self, value)
+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)
+92 -71
View File
@@ -48,12 +48,12 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import models, transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery,
Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
@@ -71,7 +71,7 @@ from pretix.base.models import (
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.event import Event_SettingsStore, SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
@@ -1494,83 +1494,104 @@ def send_expiry_warnings(sender, **kwargs):
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
qs = Order.objects.annotate(
first_date=Coalesce(
Min('all_positions__subevent__date_from'),
F('event__date_from')
events = Event.objects.filter(
Q(has_subevents=False, date_from__gte=now()) |
(Q(has_subevents=True) & Q(Exists(
SubEvent.objects.filter(event_id=OuterRef('id'), date_from__gte=now())
)))
).annotate(
reminder_days=Subquery(
Event_SettingsStore.objects.filter(
object=OuterRef('id'),
key='mail_days_download_reminder'
).exclude(
value="None"
).annotate(
val=Cast(F("value"), output_field=models.IntegerField()),
).values("val")
)
).filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by('event_id')
event_id = None
days = None
event = None
reminder_days__isnull=False,
).order_by()
for o in qs:
if o.event_id != event_id:
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
event = o.event
event_id = o.event_id
for event in events.iterator(chunk_size=10_000):
qs = event.orders.filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
)
if days is None:
continue
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
if event.has_subevents:
qs = qs.annotate(
first_date=Min('all_positions__subevent__date_from')
).filter(
Q(first_date__gte=today)
)
else:
event_reminder_date = (event.date_from - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < event_reminder_date:
continue
if not o.ticket_download_available:
qs = qs.only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by()
for o in qs:
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
if event.has_subevents:
reminder_date = ((o.first_date or event.date_from) - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
else:
reminder_date = event_reminder_date
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if not o.ticket_download_available:
continue
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=event.reminder_days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
+1 -1
View File
@@ -2930,7 +2930,7 @@ Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {
'type': int,
'default': None
'default': None # when this default is changed, send_download_reminders needs to change
},
'mail_send_download_reminder_attendee': {
'type': bool,
+6 -6
View File
@@ -94,14 +94,14 @@ class SubEventBulkForm(SubEventForm):
label=_('Start of presale'),
help_text=_('Optional. No products will be sold before this date.'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
rel_presale_end = RelativeDateTimeField(
label=_('End of presale'),
help_text=_('Optional. No products will be sold after this date. If you do not set this value, the presale '
'will end after the end date of your event.'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
skip_if_overlap = forms.BooleanField(
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
@@ -333,12 +333,12 @@ class BulkSubEventItemForm(SubEventItemForm):
rel_available_from = RelativeDateTimeField(
label=_('Available from'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
rel_available_until = RelativeDateTimeField(
label=_('Available until'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
def __init__(self, *args, **kwargs):
@@ -355,12 +355,12 @@ class BulkSubEventItemVariationForm(SubEventItemVariationForm):
rel_available_from = RelativeDateTimeField(
label=_('Available from'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
rel_available_until = RelativeDateTimeField(
label=_('Available_until'),
required=False,
limit_choices=('date_from', 'date_to'),
limit_choices=('event__date_from', 'event__date_to'),
)
def __init__(self, *args, **kwargs):
@@ -58,6 +58,7 @@
<script type="text/javascript" src="{% static "leaflet/leaflet.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/geo.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/reldate.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "sortable/Sortable.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
+1 -1
View File
@@ -431,7 +431,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
days = (self.copy_from.date_from.astimezone(tz).date() - value.astimezone(tz).date()).days
return RelativeDateWrapper(RelativeDate(
days=abs(days),
base_date_name='date_from',
base_date_name='event__date_from',
time=value.astimezone(tz).time(),
minutes=None,
is_after=days < 0,
+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':
@@ -0,0 +1,42 @@
$(document).on('pretix:bind-forms', () => {
document.querySelectorAll('.reldatetime, .reldate').forEach(container => {
const groups = container.querySelectorAll('.radio')
groups.forEach(group => {
const referenceSelect = group.querySelector('select[data-relative-choice]')
const beforeAfterSelect = group.querySelector('select[data-relation-choice]')
if (!referenceSelect || !beforeAfterSelect) return
const beforeOption = beforeAfterSelect.querySelector('option[value="before"]')
const afterOption = beforeAfterSelect.querySelector('option[value="after"]')
const updateBeforeOption = () => {
let supportsBefore = referenceSelect.selectedOptions[0].hasAttribute('data-supports-before')
if (beforeOption) {
beforeOption.disabled = !beforeOption.disabled && !supportsBefore
}
let supportsAfter = referenceSelect.selectedOptions[0].hasAttribute('data-supports-after')
if (afterOption) {
afterOption.disabled = !afterOption.disabled && !supportsAfter
}
let dirty = false
if (beforeOption.disabled && beforeAfterSelect.value === 'before') {
beforeAfterSelect.value = 'after'
dirty = true
}
if (afterOption.disabled && beforeAfterSelect.value === 'after') {
beforeAfterSelect.value = 'before'
dirty = true
}
if (dirty) {
beforeAfterSelect.dispatchEvent(new Event('change', {bubbles: true}))
}
}
referenceSelect.addEventListener('change', updateBeforeOption)
updateBeforeOption()
})
})
})
@@ -262,7 +262,7 @@ Vue.component('availbox', {
},
mounted: function() {
if (!this.$root.cart_exists && this.$root.itemnum === 1 && (!this.$root.categories[0].items[0].has_variations || this.$root.categories[0].items[0].variations.length < 2) && !this.$root.has_seating_plan ? 1 : 0) {
this.$refs.quantity.value = 1;
this.$refs.quantity.value = 1;
if (this.order_max === 1) {
this.$refs.quantity.checked = true;
}
@@ -310,7 +310,7 @@ Vue.component('availbox', {
return this.avail[0] < 100 && this.$root.waiting_list_enabled && this.item.allow_waitinglist;
},
waiting_list_url: function () {
var u = this.$root.target_url + 'w/' + widget_id + '/waitinglist/?locale=' + lang + '&item=' + this.item.id
var u = this.$root.target_url + 'w/' + widget_id + '/waitinglist/?locale=' + lang + '&item=' + this.item.id
if (this.item.has_variations) {
u += '&var=' + this.variation.id
}
@@ -863,7 +863,7 @@ var shared_alert_fragment = (
'<dialog :class="alertClasses" role="alertdialog" v-bind:aria-labelledby="$root.parent.html_id + \'-error-message\'" @close="errorClose">'
+ '<form class="pretix-widget-alert-box" method="dialog">'
+ '<p :id="$root.parent.html_id + \'-error-message\'">{{ $root.error_message }}</p>'
+ '<p><button v-if="$root.error_url_after" value="continue" autofocus v-bind:aria-describedby="$root.parent.html_id + \'-error-message\'">' + strings.continue + '</button>'
+ '<p><button v-if="$root.error_url_after" @click="errorContinue" autofocus v-bind:aria-describedby="$root.parent.html_id + \'-error-message\'">' + strings.continue + '</button>'
+ '<button v-else autofocus v-bind:aria-describedby="$root.parent.html_id + \'-error-message\'">' + strings.close + '</button></p>'
+ '</form>'
+ '<transition name="bounce">'
@@ -952,7 +952,7 @@ Vue.component('pretix-overlay', {
cancelBlockedClasses: function () {
return {
'pretix-widget-visibility-hidden': !this.cancelBlocked,
}
}
},
},
mounted () {
@@ -974,19 +974,18 @@ Vue.component('pretix-overlay', {
this.$root.lightbox.loading = false;
},
errorClose: function (e) {
var dialog = e.target;
if (dialog.returnValue == "continue" && this.$root.error_url_after) {
if (this.$root.error_url_after_new_tab) {
window.open(this.$root.error_url_after);
} else if (this.$root.overlay) {
this.$root.overlay.frame_src = this.$root.error_url_after;
this.$root.frame_loading = true;
}
}
this.$root.error_message = null;
this.$root.error_url_after = null;
this.$root.error_url_after_new_tab = false;
},
errorContinue: function () {
if (this.$root.error_url_after_new_tab) {
window.open(this.$root.error_url_after);
} else if (this.$root.overlay) {
this.$root.overlay.frame_src = this.$root.error_url_after;
this.$root.frame_loading = true;
}
},
close: function (e) {
if (this.$root.frame_loading) {
// Chrome does not allow blocking dialog.cancel event more than once
@@ -54,16 +54,16 @@ function lightboxLoaded () {
}
}
function errorClose (e: Event) {
const dialog = e.target as HTMLDialogElement
if (dialog.returnValue === 'continue' && store.overlay.errorUrlAfter) {
if (store.overlay.errorUrlAfterNewTab) {
window.open(store.overlay.errorUrlAfter)
} else {
store.overlay.frameSrc = store.overlay.errorUrlAfter
store.overlay.frameLoading = true
}
function errorContinue () {
if (store.overlay.errorUrlAfterNewTab) {
window.open(store.overlay.errorUrlAfter)
} else {
store.overlay.frameSrc = store.overlay.errorUrlAfter
store.overlay.frameLoading = true
}
}
function errorClose () {
store.overlay.errorMessage = null
store.overlay.errorUrlAfter = null
store.overlay.errorUrlAfterNewTab = false
@@ -168,6 +168,10 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('message', onMessage, false)
})
const vAutofocus = {
mounted: (el) => el.focus()
}
</script>
<template lang="pug">
@@ -181,7 +185,7 @@ Teleport(to="body")
strong {{ STRINGS.cancel_blocked }}
.pretix-widget-frame-inner(v-show="store.overlay.frameShown")
form.pretix-widget-frame-close(method="dialog")
button(ref="closeButton", :aria-label="STRINGS.close_checkout", autofocus)
button(ref="closeButton", v-autofocus, :aria-label="STRINGS.close_checkout")
svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
iframe(
@@ -200,9 +204,9 @@ Teleport(to="body")
form.pretix-widget-alert-box(method="dialog")
p(:id="errorMessageId") {{ store.overlay.errorMessage }}
p
button(v-if="store.overlay.errorUrlAfter", value="continue", autofocus, :aria-describedby="errorMessageId")
button(v-if="store.overlay.errorUrlAfter", v-autofocus, :aria-describedby="errorMessageId", @click="errorContinue")
| {{ STRINGS.continue }}
button(v-else, autofocus, :aria-describedby="errorMessageId") {{ STRINGS.close }}
button(v-else, v-autofocus, :aria-describedby="errorMessageId") {{ STRINGS.close }}
transition(name="bounce")
svg.pretix-widget-alert-icon(v-if="store.overlay.errorMessage", width="64", height="64", viewBox="0 0 1792 1792", xmlns="http://www.w3.org/2000/svg")
path(style="fill:#ffffff;", d="M 599.86438,303.72882 H 1203.5254 V 1503.4576 H 599.86438 Z")
@@ -214,7 +218,7 @@ Teleport(to="body")
path.pretix-widget-primary-color(d="M1152 896q0-106-75-181t-181-75-181 75-75 181 75 181 181 75 181-75 75-181zm512-109v222q0 12-8 23t-20 13l-185 28q-19 54-39 91 35 50 107 138 10 12 10 25t-9 23q-27 37-99 108t-94 71q-12 0-26-9l-138-108q-44 23-91 38-16 136-29 186-7 28-36 28h-222q-14 0-24.5-8.5t-11.5-21.5l-28-184q-49-16-90-37l-141 107q-10 9-25 9-14 0-25-11-126-114-165-168-7-10-7-23 0-12 8-23 15-21 51-66.5t54-70.5q-27-50-41-99l-183-27q-13-2-21-12.5t-8-23.5v-222q0-12 8-23t19-13l186-28q14-46 39-92-40-57-107-138-10-12-10-24 0-10 9-23 26-36 98.5-107.5t94.5-71.5q13 0 26 10l138 107q44-23 91-38 16-136 29-186 7-28 36-28h222q14 0 24.5 8.5t11.5 21.5l28 184q49 16 90 37l142-107q9-9 24-9 13 0 25 10 129 119 165 170 7 8 7 22 0 12-8 23-15 21-51 66.5t-54 70.5q26 50 41 98l183 28q13 2 21 12.5t8 23.5z")
.pretix-widget-lightbox-inner(v-if="store.overlay.lightbox")
form.pretix-widget-lightbox-close(method="dialog")
button(:aria-label="STRINGS.close", autofocus)
button(v-autofocus, :aria-label="STRINGS.close")
svg(:alt="STRINGS.close", height="16", viewBox="0 0 512 512", width="16", xmlns="http://www.w3.org/2000/svg")
path(fill="#fff", d="M437.5,386.6L306.9,256l130.6-130.6c14.1-14.1,14.1-36.8,0-50.9c-14.1-14.1-36.8-14.1-50.9,0L256,205.1L125.4,74.5 c-14.1-14.1-36.8-14.1-50.9,0c-14.1,14.1-14.1,36.8,0,50.9L205.1,256L74.5,386.6c-14.1,14.1-14.1,36.8,0,50.9 c14.1,14.1,36.8,14.1,50.9,0L256,306.9l130.6,130.6c14.1,14.1,36.8,14.1,50.9,0C451.5,423.4,451.5,400.6,437.5,386.6z")
figure.pretix-widget-lightbox-image
+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()
+14 -14
View File
@@ -473,7 +473,7 @@ class QuotaTestCase(BaseQuotaTestCase):
@classscope(attr='o')
def test_waitinglist_auto_disable(self):
self.event.settings.waiting_list_auto_disable = RelativeDateWrapper(
RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=True)
RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=True)
)
self.quota.items.add(self.item1)
self.quota.size = 1
@@ -484,7 +484,7 @@ class QuotaTestCase(BaseQuotaTestCase):
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
self.event.settings.waiting_list_auto_disable = RelativeDateWrapper(
RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=False)
RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=False)
)
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1))
self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
@@ -1192,7 +1192,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.subevent = se2
self.op2.save()
self.event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
self.order.status = Order.STATUS_EXPIRED
@@ -1347,7 +1347,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.subevent = se2
self.op2.save()
self.event.settings.set('last_order_modification_date', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
assert self.order.can_modify_answers
self.op2.subevent = se3
@@ -1363,7 +1363,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=datetime.timezone.utc)
self.event.save()
self.event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
assert self.order.payment_term_last == datetime.datetime(2017, 5, 1, 23, 59, 59, tzinfo=datetime.timezone.utc)
@@ -1379,7 +1379,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.subevent = se2
self.op2.save()
self.event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
assert self.order.payment_term_last > now()
self.op2.subevent = se3
@@ -1395,7 +1395,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=datetime.timezone.utc)
self.event.save()
self.event.settings.set('ticket_download_date', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
assert self.order.ticket_download_date == datetime.datetime(2017, 5, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
@@ -1411,7 +1411,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.subevent = se2
self.op2.save()
self.event.settings.set('ticket_download_date', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
assert self.order.ticket_download_date > now()
self.op2.subevent = se3
@@ -1582,13 +1582,13 @@ class OrderTestCase(BaseQuotaTestCase):
assert self.order.user_cancel_deadline is None
self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper(
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_cancel_deadline > now()
assert self.order.user_cancel_allowed
self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper(
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_cancel_deadline < now()
@@ -1607,7 +1607,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.save()
self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper(
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_cancel_deadline < now()
@@ -2018,13 +2018,13 @@ class OrderTestCase(BaseQuotaTestCase):
assert self.order.user_change_deadline is None
self.event.settings.set('change_allow_user_until', RelativeDateWrapper(
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_change_deadline > now()
assert self.order.user_change_allowed
self.event.settings.set('change_allow_user_until', RelativeDateWrapper(
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_change_deadline < now()
@@ -2046,7 +2046,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.op2.save()
self.event.settings.set('change_allow_user_until', RelativeDateWrapper(
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='event__date_from', minutes=None)
))
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_change_deadline < now()
+2 -2
View File
@@ -204,7 +204,7 @@ def test_expiry_last_relative(event):
event.date_from = now() + timedelta(days=5)
event.save()
event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today,
@@ -245,7 +245,7 @@ def test_expiry_last_relative_subevents(event):
)
event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
order = _create_order(event, email='dummy@example.org', positions=[cp1, cp2],
now_dt=today,
+6 -6
View File
@@ -133,7 +133,7 @@ def test_availability_date_relative(event):
event.save()
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
utc = datetime.timezone.utc
@@ -150,7 +150,7 @@ def test_availability_start_relative(event):
event.save()
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_start', RelativeDateWrapper(
RelativeDate(days=2, time=datetime.time(12, 0), base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(12, 0), base_date_name='event__date_from', minutes=None)
))
utc = datetime.timezone.utc
@@ -193,12 +193,12 @@ def test_availability_date_cart_relative_subevents(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days=3, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=3, time=None, base_date_name='event__date_from', minutes=None)
))
assert prov._is_available_by_time(cart_id="123")
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days=4, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=4, time=None, base_date_name='event__date_from', minutes=None)
))
assert not prov._is_available_by_time(cart_id="123")
@@ -233,11 +233,11 @@ def test_availability_date_order_relative_subevents(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days=3, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=3, time=None, base_date_name='event__date_from', minutes=None)
))
assert prov._is_available_by_time(order=order)
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days=4, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=4, time=None, base_date_name='event__date_from', minutes=None)
))
assert not prov._is_available_by_time(order=order)
+94 -36
View File
@@ -19,13 +19,13 @@
# 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/>.
#
from datetime import datetime, time
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
import pytest
from django_scopes import scope
from pretix.base.models import Event, Organizer
from pretix.base.models import Event, Order, Organizer
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
TOKYO = ZoneInfo('Asia/Tokyo')
@@ -40,7 +40,6 @@ def event():
date_from=datetime(2017, 12, 27, 5, 0, 0, tzinfo=TOKYO),
presale_start=datetime(2017, 12, 1, 5, 0, 0, tzinfo=TOKYO),
plugins='pretix.plugins.banktransfer'
)
event.settings.timezone = "Asia/Tokyo"
return event
@@ -56,57 +55,57 @@ def test_absolute_date(event):
@pytest.mark.django_db
def test_relative_date_without_time(event):
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None))
assert rdw.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None, is_after=True))
assert rdw.to_string() == 'RELDATE/1/-/event__date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None, is_after=True))
assert rdw.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 28, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/date_from/after'
assert rdw.to_string() == 'RELDATE/1/-/event__date_from/after'
@pytest.mark.django_db
def test_relative_date_other_base_point(event):
with scope(organizer=event.organizer):
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_start', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_start', minutes=None))
assert rdw.datetime(event) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/presale_start/'
assert rdw.to_string() == 'RELDATE/1/-/event__presale_start/'
# presale_end is unset, defaults to date_from
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_end', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_end', minutes=None))
assert rdw.datetime(event) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/presale_end/'
assert rdw.to_string() == 'RELDATE/1/-/event__presale_end/'
# subevent base
se = event.subevents.create(name="SE1", date_from=datetime(2017, 11, 27, 5, 0, 0, tzinfo=TOKYO))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None))
assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO)
# presale_start is unset on subevent, default to event
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_start', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_start', minutes=None))
assert rdw.datetime(se) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO)
# presale_end is unset on all, default to date_from of subevent
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_end', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_end', minutes=None))
assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO)
@pytest.mark.django_db
def test_relative_date_in_minutes(event):
rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60))
assert rdw.to_string() == 'RELDATE/minutes/60/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=60))
assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/'
assert rdw.datetime(event) == datetime(2017, 12, 27, 4, 0, 0, tzinfo=TOKYO)
rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60, is_after=True))
assert rdw.to_string() == 'RELDATE/minutes/60/date_from/after'
rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=60, is_after=True))
assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/after'
assert rdw.datetime(event) == datetime(2017, 12, 27, 6, 0, 0, tzinfo=TOKYO)
@pytest.mark.django_db
def test_relative_date_with_time(event):
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/08:05:13/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/'
assert rdw.datetime(event) == datetime(2017, 12, 26, 8, 5, 13, tzinfo=TOKYO)
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='date_from', minutes=None, is_after=True))
assert rdw.to_string() == 'RELDATE/1/08:05:13/date_from/after'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(8, 5, 13), base_date_name='event__date_from', minutes=None, is_after=True))
assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/after'
assert rdw.datetime(event) == datetime(2017, 12, 28, 8, 5, 13, tzinfo=TOKYO)
@@ -115,35 +114,94 @@ def test_relative_date_with_time_around_dst(event):
event.settings.timezone = "Europe/Berlin"
event.date_from = datetime(2020, 3, 29, 18, 0, 0, tzinfo=BERLIN)
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/'
assert rdw.datetime(event) == datetime(2020, 3, 28, 18, 0, 0, tzinfo=BERLIN)
rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/'
assert rdw.datetime(event) == datetime(2020, 3, 29, 2, 30, 0, tzinfo=BERLIN)
event.date_from = datetime(2020, 10, 25, 18, 0, 0, tzinfo=BERLIN)
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=time(18, 0, 0), base_date_name='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/'
assert rdw.datetime(event) == datetime(2020, 10, 24, 18, 0, 0, tzinfo=BERLIN)
rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=0, time=time(2, 30, 0), base_date_name='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/'
assert rdw.datetime(event) == datetime(2020, 10, 25, 2, 30, 0, tzinfo=BERLIN)
def test_unserialize():
def test_unserialize_backwards_compatibility():
d = datetime(2017, 12, 25, 10, 0, 0, tzinfo=TOKYO)
rdw = RelativeDateWrapper.from_string(d.isoformat())
assert rdw.data == d
# keeping the test for the old from_string_format to ensure that we don't break anything
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_from/')
assert rdw.data == RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None)
# keeping the test for the old from_string_format to ensure that we don't break anything
rdw = RelativeDateWrapper.from_string('RELDATE/1/18:05:13/date_from/')
assert rdw.data == RelativeDate(days=1, time=time(18, 5, 13), base_date_name='date_from', minutes=None)
# keeping the test for the old from_string_format to ensure that we don't break anything
rdw = RelativeDateWrapper.from_string('RELDATE/minutes/60/date_from/')
assert rdw.data == RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60)
def test_backwards_compatibility():
# the data model of RelativeDate had to be extended to support other models as relation target
# previously only:
# - date_from
# - date_to
# - date_admission
# - presale_start
# - presale_end
# where valid values for the fourth slot (base_date_names) of the serialized form.
# the relationship in this case always pointed at event
# so any preexisting base_date_names without __ should continue to work and upgrade to event__{old_base_date_name}
d = datetime(2017, 12, 25, 10, 0, 0, tzinfo=TOKYO)
rdw = RelativeDateWrapper.from_string(d.isoformat())
assert rdw.data == d
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_from/')
assert rdw.data == RelativeDate(days=1, time=None, base_date_name='date_from', minutes=None)
assert rdw.to_string() == 'RELDATE/1/-/event__date_from/'
rdw = RelativeDateWrapper.from_string('RELDATE/1/18:05:13/date_from/')
assert rdw.data == RelativeDate(days=1, time=time(18, 5, 13), base_date_name='date_from', minutes=None)
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_to/')
assert rdw.to_string() == 'RELDATE/1/-/event__date_to/'
rdw = RelativeDateWrapper.from_string('RELDATE/minutes/60/date_from/')
assert rdw.data == RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60)
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_admission/')
assert rdw.to_string() == 'RELDATE/1/-/event__date_admission/'
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/presale_start/')
assert rdw.to_string() == 'RELDATE/1/-/event__presale_start/'
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/presale_end/')
assert rdw.to_string() == 'RELDATE/1/-/event__presale_end/'
# new order base_date_names should not work without __
with pytest.raises(TypeError):
RelativeDateWrapper.from_string('RELDATE/1/-/datetime/')
with pytest.raises(TypeError):
RelativeDateWrapper.from_string('RELDATE/1/-/expires/')
@pytest.mark.django_db
def test_relative_to_order(event):
with scope(organizer=event.organizer):
order_moment = datetime(2020, 3, 29, 18, 0, 0, tzinfo=TOKYO)
order = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
datetime=order_moment,
expires=order_moment + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=23, locale='en'
)
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='order__datetime', minutes=None, is_after=True))
assert rdw.datetime(order).astimezone(TOKYO) == datetime(2020, 3, 30, 18, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/order__datetime/after'
+1 -1
View File
@@ -235,7 +235,7 @@ class WaitingListTestCase(TestCase):
self.event.settings.set('waiting_list_enabled', True)
self.event.settings.set('waiting_list_auto', True)
self.event.settings.waiting_list_auto_disable = RelativeDateWrapper(
RelativeDate(days=0, time=None, base_date_name='date_from', minutes=20, is_after=False)
RelativeDate(days=0, time=None, base_date_name='event__date_from', minutes=20, is_after=False)
)
self.event.save()
with scope(organizer=self.o):
+2 -2
View File
@@ -481,7 +481,7 @@ class EventsTest(SoupTest):
'payment_term_last_0': 'absolute',
'payment_term_last_1': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'),
'payment_term_last_2': '0',
'payment_term_last_3': 'date_from',
'payment_term_last_3': 'event__date_from',
'tax_rule_payment': 'default',
})
assert doc.select('.alert-danger')
@@ -496,7 +496,7 @@ class EventsTest(SoupTest):
'payment_term_last_0': 'relative',
'payment_term_last_1': '',
'payment_term_last_2': '10',
'payment_term_last_3': 'date_from',
'payment_term_last_3': 'event__date_from',
'tax_rule_payment': 'default',
})
assert doc.select('.alert-danger')
+28 -28
View File
@@ -230,19 +230,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_1': '',
'rel_presale_end_0': 'relative',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -328,19 +328,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_1': '',
'rel_presale_end_0': 'relative',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -403,19 +403,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_1': '',
'rel_presale_end_0': 'relative',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -479,19 +479,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_1': '',
'rel_presale_end_0': 'relative',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '02:30:00',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -567,19 +567,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_1': '',
'rel_presale_end_0': 'relative',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -639,19 +639,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_0': 'unset',
'rel_presale_end_1': '',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
@@ -711,19 +711,19 @@ class SubEventsTest(SoupTest):
'rel_presale_start_0': 'unset',
'rel_presale_start_1': '',
'rel_presale_start_2': '1',
'rel_presale_start_3': 'date_from',
'rel_presale_start_3': 'event__date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'date_from',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_7': 'before',
'rel_presale_start_8': 'before',
'rel_presale_end_0': 'unset',
'rel_presale_end_1': '',
'rel_presale_end_2': '1',
'rel_presale_end_3': 'date_from',
'rel_presale_end_3': 'event__date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'date_from',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
+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']
+1 -1
View File
@@ -1132,7 +1132,7 @@ class WaitingListTest(EventTestMixin, SoupTest):
def test_auto_disable(self):
self.event.settings.set('waiting_list_enabled', True)
self.event.settings.waiting_list_auto_disable = RelativeDateWrapper(
RelativeDate(days=900, time=datetime.time(9, 0, 0), base_date_name='date_from', minutes=None, is_after=False)
RelativeDate(days=900, time=datetime.time(9, 0, 0), base_date_name='event__date_from', minutes=None, is_after=False)
)
response = self.client.get(
'/%s/%s/' % (self.orga.slug, self.event.slug)
+1 -1
View File
@@ -1527,7 +1527,7 @@ class OrderChangeAddonsTest(BaseOrdersTest):
self.order.save()
self._subevent_setup()
self.event.settings.set('payment_term_last', RelativeDateWrapper(
RelativeDate(days=2, time=None, base_date_name='date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='event__date_from', minutes=None)
))
response = self.client.get(
+1 -1
View File
@@ -1076,7 +1076,7 @@ class OrdersTest(BaseOrdersTest):
self.event.date_from = now() + datetime.timedelta(days=3)
self.event.save()
self.event.settings.set('ticket_download_date', RelativeDateWrapper(RelativeDate(
base_date_name='date_from', days=2, time=None, minutes=None
base_date_name='event__date_from', days=2, time=None, minutes=None
)))
response = self.client.post(
'/%s/%s/order/%s/%s/download/%d/testdummy' % (self.orga.slug, self.event.slug, self.order.code,