Compare commits

..
Author SHA1 Message Date
Lukas Bockstaller ae5fa83da5 store payment.info during _execute_payment asap 2026-08-06 14:30:40 +02:00
Lukas Bockstaller 4ae628737d cleanup logic regarding uninteresting resource_type 2026-08-06 14:08:47 +02:00
Lukas Bockstaller e5f928323b add test 2026-08-06 13:38:25 +02:00
3a22eeaa75 Update src/pretix/plugins/paypal2/views.py
Co-authored-by: Phin Wolkwitz <wolkwitz@pretix.eu>
2026-08-06 13:04:31 +02:00
Lukas Bockstaller 70c55730a3 handle capture ressource 2026-08-06 12:49:24 +02:00
Lukas Bockstaller 588abffdd3 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
2026-08-06 11:38:08 +02:00
36 changed files with 361 additions and 1154 deletions
-2
View File
@@ -71,8 +71,6 @@ 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 ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` 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": 1,
"addon_to": 12374,
"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.annul'),
('POST', 'api-v1:checkinrpc.annull'),
('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.annul'),
('POST', 'api-v1:checkinrpc.annull'),
('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.annul'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
)
-1
View File
@@ -90,7 +90,6 @@ 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,11 +839,6 @@ 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(
@@ -1071,7 +1066,6 @@ 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,18 +314,3 @@ 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
+4 -21
View File
@@ -330,24 +330,9 @@ class BasePaymentProvider:
payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments.
If the decision doesn't depend on the specific payment, then only implementing
``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)``
is expected to take this into account.
As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)``
to check if aborting this pending payment is possible.
"""
return False
def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool:
"""
Experimental: This might change during upcomming releases.
Whether or not a user can abort a payment in pending state to switch to another
payment method. This returns ``self.abort_pending_allowed`` by default which is
no guarantee that aborting a pending payment can never happen, it just hides the
frontend button to avoid users accidentally committing double payments.
"""
return self.abort_pending_allowed
@property
def requires_invoice_immediately(self):
"""
@@ -1034,12 +1019,10 @@ class BasePaymentProvider:
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
On failure, you should raise a PaymentException.
"""
if payment.state == OrderPayment.PAYMENT_STATE_PENDING:
if not self._payment_abort_pending_allowed(payment):
raise PaymentException(_(
"This payment is already being processed and cannot be canceled any more."
))
if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed:
raise PaymentException(_(
"This payment is already being processed and can not be canceled any more."
))
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save(update_fields=['state'])
+155 -373
View File
@@ -19,245 +19,30 @@
# 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 dataclasses import dataclass
from typing import (
TYPE_CHECKING, Iterable, List, Literal, Optional, Tuple, Union, cast,
)
from typing import Union
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 Promise, lazy
from django.utils.functional import lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.base.forms.widgets import OptionAttrsSelect
BASE_CHOICES = (
('date_from', _('Event start')),
('date_to', _('Event end')),
('date_admission', _('Event admission')),
('presale_start', _('Presale start')),
('presale_end', _('Presale end')),
)
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
RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from'))
class RelativeDateWrapper:
@@ -265,53 +50,130 @@ 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, 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.
def date(self, event) -> datetime.date:
from .models import SubEvent
: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:
return self.data.date(base)
if self.data.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
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.
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
: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:
return self.data.datetime(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.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
def to_string(self) -> str:
if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data.isoformat()
else:
return self.data.to_string()
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 '',
)
@classmethod
def from_string(cls, input: str):
if input.startswith('RELDATE/'):
data = RelativeDate.from_string(input)
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))
else:
data = parser.parse(input)
return RelativeDateWrapper(data)
@@ -325,6 +187,7 @@ BEFORE_AFTER_CHOICE = (
('after', _('after')),
)
reldatetimeparts = namedtuple('reldatetimeparts', (
"status", # 0
"absolute", # 1
@@ -339,14 +202,6 @@ 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
@@ -354,7 +209,6 @@ 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]
@@ -366,29 +220,21 @@ class RelativeDateTimeWidget(forms.MultiWidget):
tf = get_format('TIME_INPUT_FORMATS')[0]
return datetime.time(8, 30, 0).strftime(tf)
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldatetimeparts(
widgets = 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=OptionAttrsSelect(
attrs={'data-relative-choice': True},
choices=choices,
option_attrs=_get_choice_validation_obj(base_choices)
),
rel_mins_relationto=forms.Select(choices=base_choices),
rel_days_timeofday=forms.TimeInput(
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
),
rel_mins_number=forms.NumberInput(),
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),
)))
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),
)
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
@@ -401,10 +247,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="unset",
absolute=None,
rel_days_number=1,
rel_mins_relationto="event__date_from",
rel_mins_relationto="date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="event__date_from",
rel_days_relationto="date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
@@ -413,10 +259,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="absolute",
absolute=value.data,
rel_days_number=1,
rel_mins_relationto="event__date_from",
rel_mins_relationto="date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="event__date_from",
rel_days_relationto="date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
@@ -425,10 +271,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative_minutes",
absolute=None,
rel_days_number=None,
rel_mins_relationto=value.data.key,
rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=None,
rel_mins_number=value.data.minutes,
rel_days_relationto=value.data.key,
rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -436,10 +282,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_mins_relationto=value.data.key,
rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=value.data.time,
rel_mins_number=0,
rel_days_relationto=value.data.key,
rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -463,41 +309,17 @@ 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')
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)
]
choices = [(k, v) for k, v in BASE_CHOICES if k in limit]
else:
choices = BASE_CHOICES
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
@@ -507,7 +329,7 @@ class RelativeDateTimeField(forms.MultiValueField):
),
rel_mins_relationto=forms.ChoiceField(
choices=choices,
required=False,
required=False
),
rel_days_timeofday=forms.TimeField(
required=False,
@@ -517,7 +339,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,
@@ -528,9 +350,8 @@ class RelativeDateTimeField(forms.MultiValueField):
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices)
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices)
kwargs.pop('max_length', 0)
kwargs.pop('empty_value', 0)
super().__init__(
@@ -538,24 +359,21 @@ class RelativeDateTimeField(forms.MultiValueField):
)
def set_event(self, event):
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
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)
]
def compress(self, data_list):
if not data_list:
return None
data = reldatetimeparts(*data_list)
if data.status == 'unset':
return None
elif data.status == 'absolute':
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
return None
elif data.status == 'relative_minutes':
return RelativeDateWrapper(RelativeDate(
days=0,
@@ -586,18 +404,6 @@ 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)
@@ -618,20 +424,15 @@ class RelativeDateWidget(RelativeDateTimeWidget):
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
self.base_choices = kwargs.pop('base_choices')
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldateparts(
widgets = reldateparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateInput(
attrs={'class': 'datepickerfield'}
),
rel_days_number=forms.NumberInput(),
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},),
)))
rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
)
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
def decompress(self, value):
@@ -659,7 +460,7 @@ class RelativeDateWidget(RelativeDateTimeWidget):
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_days_relationto=value.data.key,
rel_days_relationto=value.data.base_date_name,
rel_days_relation="after" if value.data.is_after else "before"
)
@@ -673,15 +474,6 @@ 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,
@@ -694,37 +486,33 @@ class RelativeDateField(RelativeDateTimeField):
required=False
),
rel_days_relationto=forms.ChoiceField(
choices=choices,
required=False,
choices=BASE_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=possible_choices)
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
forms.MultiValueField.__init__(
self, fields=fields, require_all_fields=False, *args, **kwargs
)
def set_event(self, event):
choices = [
(c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None)
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, 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 == 'unset':
return None
elif data.status == 'absolute':
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
return None
else:
return RelativeDateWrapper(RelativeDate(
days=data.rel_days_number,
@@ -737,14 +525,8 @@ class RelativeDateField(RelativeDateTimeField):
data = reldateparts(*value)
if data.status == 'absolute' and not data.absolute:
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)))
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
raise ValidationError(self.error_messages['incomplete'])
return forms.MultiValueField.clean(self, value)
+3 -4
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
from django.db import IntegrityError, transaction
from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,7 +59,6 @@ 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 (
@@ -1044,10 +1043,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate:
_save_answers(op, answers, given_answers)
with conditional_atomic(not simulate):
with transaction.atomic():
# 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 and not simulate:
if type != Checkin.TYPE_EXIT:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
+71 -92
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 models, transaction
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery,
Sum, Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
)
from django.db.models.functions import Cast, Greatest
from django.db.models.functions import Coalesce, 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 Event_SettingsStore, SubEvent
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
@@ -1494,104 +1494,83 @@ 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)
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")
qs = Order.objects.annotate(
first_date=Coalesce(
Min('all_positions__subevent__date_from'),
F('event__date_from')
)
).filter(
reminder_days__isnull=False,
).order_by()
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
for event in events.iterator(chunk_size=10_000):
qs = event.orders.filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
)
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
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:
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:
continue
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:
if not o.ticket_download_available:
continue
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:
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
if not o.ticket_download_available:
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 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
)
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
)
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
+1 -1
View File
@@ -193,7 +193,7 @@ class QuotaAvailability:
lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])])
if rc.exists(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}'):
return
rc.set(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', ex=10)
rc.setex(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', 10)
update = defaultdict(list)
for q in quotas:
+1 -1
View File
@@ -2930,7 +2930,7 @@ Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {
'type': int,
'default': None # when this default is changed, send_download_reminders needs to change
'default': None
},
'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=('event__date_from', 'event__date_to'),
limit_choices=('date_from', '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=('event__date_from', 'event__date_to'),
limit_choices=('date_from', '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=('event__date_from', 'event__date_to'),
limit_choices=('date_from', 'date_to'),
)
rel_available_until = RelativeDateTimeField(
label=_('Available until'),
required=False,
limit_choices=('event__date_from', 'event__date_to'),
limit_choices=('date_from', '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=('event__date_from', 'event__date_to'),
limit_choices=('date_from', 'date_to'),
)
rel_available_until = RelativeDateTimeField(
label=_('Available_until'),
required=False,
limit_choices=('event__date_from', 'event__date_to'),
limit_choices=('date_from', 'date_to'),
)
def __init__(self, *args, **kwargs):
@@ -58,7 +58,6 @@
<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='event__date_from',
base_date_name='date_from',
time=value.astimezone(tz).time(),
minutes=None,
is_after=days < 0,
-9
View File
@@ -288,15 +288,6 @@ 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!
+12 -69
View File
@@ -23,7 +23,7 @@ import json
import logging
import urllib.parse
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from datetime import timedelta
from decimal import Decimal
from django import forms
@@ -193,31 +193,6 @@ class PaypalSettingsHolder(BasePaymentProvider):
}
)
)),
('allow_retries_during_compliance_hold',
forms.BooleanField(
label=_('Allow further payments during compliance hold'),
help_text=_(
'PayPals fraud prevention might block processing of individual payments for a considerable amount '
'of time. The payment is marked as "pending" during this time window. You can allow your customers to '
'start another payment attempts during that window. This might result in them being charged twice if the'
'original payment is approved.'
),
required=False
)),
('timeout_payment_during_compliance_hold',
forms.IntegerField(
label=_('Timeout further payment attempts'),
help_text=_(
'Time duration in minutes after which another payment attempt is possible, while the last payment is '
'still under investigation.'
),
required=False,
widget=forms.NumberInput(
attrs={
'data-checkbox-dependency': '#id_payment_paypal_allow_retries_during_compliance_hold',
}
)
)),
]
@@ -540,16 +515,8 @@ class PaypalMethod(BasePaymentProvider):
'XPF': 0,
}))
def _payment_abort_pending_allowed(self, payment) -> bool:
if not self.settings.get('allow_retries_during_compliance_hold', as_type=bool, default=True):
return False
if payment.info_data.get('create_time', False):
create_time = datetime.fromisoformat(payment.info_data['create_time'])
duration = self.settings.get('timeout_payment_during_compliance_hold', as_type=int, default=10)
if datetime.now(tz=timezone.utc) - create_time > timedelta(minutes=duration):
return True
@property
def abort_pending_allowed(self):
return False
def _create_paypal_order(self, request, payment=None, cart_total=None):
@@ -678,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:
# payment is already confirmed; possible return-view/webhook race-condition
logger.warning('payment is already confirmed; possible return-view/webhook race-condition')
return
try:
@@ -711,8 +678,6 @@ class PaypalMethod(BasePaymentProvider):
else:
pp_captured_order = response.result
payment.info = json.dumps(pp_captured_order.dict())
if pp_captured_order.status == 'APPROVED':
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
try:
@@ -867,7 +832,6 @@ 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
@@ -877,35 +841,15 @@ 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:
stuck_in_compliance = False
retry = self._payment_abort_pending_allowed(payment)
retry = True
try:
for purchase_unit in payment.info_data['purchase_units']:
for capture in purchase_unit['payments']['captures']:
if capture['status'] == "PENDING":
stuck_in_compliance = True
except KeyError:
pass
try:
if payment.info_data.get('status') == "APPROVED":
stuck_in_compliance = True
except (KeyError):
if (
payment.info
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING'
):
retry = False
except (KeyError, IndexError):
pass
error = payment.info_data.get("error", {})
@@ -913,8 +857,7 @@ class PaypalMethod(BasePaymentProvider):
template = get_template('pretixplugins/paypal2/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'stuck_in_compliance': stuck_in_compliance, 'retry': retry, 'order': payment.order,
'is_known_issue': is_known_issue}
'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
-2
View File
@@ -166,8 +166,6 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
settings_hierarkey.add_default('payment_paypal_allow_retries_during_compliance_hold', True, bool)
settings_hierarkey.add_default('payment_paypal_timeout_payment_during_compliance_hold', 10, int)
def _nonce(request):
@@ -6,12 +6,9 @@
Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the
payment succeeding on a second or third attempt. You can also try other payment methods, if available.
{% endblocktrans %}</div>
{% elif stuck_in_compliance %}
{% else %}
<p>{% blocktrans trimmed %}
Your payment is being processed by PayPal. This takes longer than usual. You can wait until PayPal
acknowledges the payment or you can try paying again with this or another payment method.
This might result in you being charged twice in case PayPal allows your initial payment attempt.
Please contact us to resolve this case.
Our attempt to execute your payment via PayPal has failed. Please try again or contact us.
{% endblocktrans %}</p>
{% endif %}
{% else %}
+10 -53
View File
@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
import json
import logging
import re
from decimal import Decimal
from django.contrib import messages
@@ -362,13 +361,7 @@ def webhook(request, *args, **kwargs):
if event_json['resource_type'] == 'checkout-order':
payloadid = event_json['resource']['id']
elif event_json['resource_type'] == 'refund' or event_json['resource_type'] == 'capture':
payloadid = get_order_id(event_json.get('resource', {}).get('links', []))
if payloadid is None:
# if we get a PAYMENT.CAPTURE.DECLINED webhook because a capture wasn't created due to
# violated validations, then it is labeled as a `capture` ressource_type but is in fact
# an `order` ressource_type as there is no `capture`. So we have to fall back
# See test_webhook_capture_declined for a redacted payload we've received
payloadid = event_json['resource']['id']
payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1]
else:
return HttpResponse("Not interested in this resource type", status=200)
@@ -433,22 +426,6 @@ def webhook(request, *args, **kwargs):
payment.info = json.dumps(sale.dict())
payment.save()
# the captures[] of the sales object only is populated if the capture request isn't rejected.
# the capture request might be rejected if certain validations aren't met OR if the payment is
# DECLINED, nevertheless we will get a webhook informing us about "PAYMENT.CAPTURE.DECLINED".
# With no trace of it in `sale`.
# So now we have to leave our current pattern of making only decisions based upon the
# complete payment object (and checking whenever we receive a webhook), and instead need to fail
# payment directly.
# Otherwise we are caught in a loop:
# 1. We get a webhook and get `sale`
# 2. We see no proof of a capture attempt in `sale`
# 3. We call execute_payment and trigger a new "PAYMENT.CAPTURE.DECLINED" webhook, GOTO 1
if event_json['event_type'] == "PAYMENT.CAPTURE.DECLINED":
payment.fail(log_data={'status': event_json['event_type']})
logger.exception('PayPal Webhook PAYMENT.CAPTURE.DECLINED: {}'.format(event_json))
return HttpResponse(status=200)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
if event_json['resource_type'] == 'refund':
try:
@@ -494,9 +471,8 @@ def webhook(request, *args, **kwargs):
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
if sale['status'] == 'COMPLETED':
any_captures = False
all_captures_completed = True
any_pending_review = False
any_failed = None
for purchaseunit in sale['purchase_units']:
for capture in purchaseunit['payments']['captures']:
try:
@@ -505,28 +481,17 @@ def webhook(request, *args, **kwargs):
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
if capture['status'] in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
pass
elif capture['status'] in ("DECLINED", "FAILED"):
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
all_captures_completed = False
any_failed = True
elif capture['status'] in ('PENDING'):
all_captures_completed = False
if capture.get('status_details', {}).get('reason', "") == "PENDING_REVIEW":
any_pending_review = True
else:
raise ValueError("Unknown paypal capture state: {}".format(capture['status']))
if all_captures_completed:
any_captures = True
if any_captures and all_captures_completed:
try:
payment.info = json.dumps(sale.dict())
payment.save(update_fields=['info'])
payment.confirm()
prov.log_payment_duration(payment)
except Quota.QuotaExceededException:
pass
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save(update_fields=['state'])
if any_failed:
payment.fail()
elif sale['status'] == 'APPROVED':
try:
request.session['payment_paypal_oid'] = payment.info_data['id']
@@ -560,17 +525,9 @@ def isu_disconnect(request, **kwargs):
}))
ORDER_ID_RE = re.compile(r"/checkout/orders/([^/?]+)")
def get_order_id(links):
def get_link(links, rel):
for link in links:
if link.get('rel', "") == "up" and link.get('href', None) is not None:
return link['href'].split('/')[-1]
for link in links:
match = ORDER_ID_RE.search(link.get("href", ""))
if match:
return match.group(1)
if link['rel'] == rel:
return link
return None
+3 -4
View File
@@ -349,7 +349,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
pp = lp.payment_provider
ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment'])
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(lp):
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
ctx['can_pay'] = False
ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True
@@ -611,8 +611,7 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
if self.open_payment:
pp = self.open_payment.payment_provider
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(
self.open_payment):
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
messages.error(request, _('A payment is currently pending for this order.'))
return redirect(self.get_order_url())
@@ -1719,7 +1718,7 @@ class OrderChangeMixin:
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING):
if not p.payment_provider._payment_abort_pending_allowed(p):
if not p.payment_provider.abort_pending_allowed:
raise OrderError(_('You may not change your order in a way that requires additional payment while '
'we are processing your current payment. Please check back after your current '
'payment has been accepted.'))
@@ -1,42 +0,0 @@
$(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()
})
})
})
-25
View File
@@ -1,25 +0,0 @@
#
# 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,7 +25,6 @@ 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
@@ -37,7 +36,6 @@ 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
@@ -1741,41 +1739,3 @@ 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='event__date_from', minutes=20, is_after=True)
RelativeDate(days=0, time=None, base_date_name='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='event__date_from', minutes=20, is_after=False)
RelativeDate(days=0, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=4, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(14, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=datetime.time(12, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=3, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=4, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=3, time=None, base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=4, time=None, base_date_name='date_from', minutes=None)
))
assert not prov._is_available_by_time(order=order)
+31 -89
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, timedelta
from datetime import datetime, time
from zoneinfo import ZoneInfo
import pytest
from django_scopes import scope
from pretix.base.models import Event, Order, Organizer
from pretix.base.models import Event, Organizer
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
TOKYO = ZoneInfo('Asia/Tokyo')
@@ -40,6 +40,7 @@ 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
@@ -55,57 +56,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='event__date_from', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='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/-/event__date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None, is_after=True))
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.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 28, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/event__date_from/after'
assert rdw.to_string() == 'RELDATE/1/-/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='event__presale_start', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_start', minutes=None))
assert rdw.datetime(event) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/event__presale_start/'
assert rdw.to_string() == 'RELDATE/1/-/presale_start/'
# presale_end is unset, defaults to date_from
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='event__presale_end', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='presale_end', minutes=None))
assert rdw.datetime(event) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/event__presale_end/'
assert rdw.to_string() == 'RELDATE/1/-/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='event__date_from', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='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='event__presale_start', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='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='event__presale_end', minutes=None))
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='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='event__date_from', minutes=60))
assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/'
rdw = RelativeDateWrapper(RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60))
assert rdw.to_string() == 'RELDATE/minutes/60/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='event__date_from', minutes=60, is_after=True))
assert rdw.to_string() == 'RELDATE/minutes/60/event__date_from/after'
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'
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='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/'
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/'
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='event__date_from', minutes=None, is_after=True))
assert rdw.to_string() == 'RELDATE/1/08:05:13/event__date_from/after'
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'
assert rdw.datetime(event) == datetime(2017, 12, 28, 8, 5, 13, tzinfo=TOKYO)
@@ -114,94 +115,35 @@ 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='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/'
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/'
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='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/'
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/'
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='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/1/18:00:00/event__date_from/'
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/'
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='event__date_from', minutes=None))
assert rdw.to_string() == 'RELDATE/0/02:30:00/event__date_from/'
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/'
assert rdw.datetime(event) == datetime(2020, 10, 25, 2, 30, 0, tzinfo=BERLIN)
def test_unserialize_backwards_compatibility():
def test_unserialize():
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.to_string() == 'RELDATE/1/-/event__date_from/'
rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_to/')
assert rdw.to_string() == 'RELDATE/1/-/event__date_to/'
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='event__date_from', minutes=20, is_after=False)
RelativeDate(days=0, time=None, base_date_name='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': 'event__date_from',
'payment_term_last_3': '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': 'event__date_from',
'payment_term_last_3': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '02:30:00',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': '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': 'event__date_from',
'rel_presale_start_3': 'date_from',
'rel_presale_start_4': '',
'rel_presale_start_5': '',
'rel_presale_start_6': 'event__date_from',
'rel_presale_start_6': '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': 'event__date_from',
'rel_presale_end_3': 'date_from',
'rel_presale_end_4': '13:29:31',
'rel_presale_end_5': '',
'rel_presale_end_6': 'event__date_from',
'rel_presale_end_6': 'date_from',
'rel_presale_end_7': 'before',
'rel_presale_end_8': 'before',
'quotas-TOTAL_FORMS': '1',
-25
View File
@@ -224,28 +224,3 @@ 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']
-206
View File
@@ -244,61 +244,6 @@ def get_test_refund():
}
def get_test_order_review_pending():
return {'id': '806440346Y391300T',
'intent': 'CAPTURE',
'status': 'COMPLETED',
'purchase_units': [{'reference_id': 'default',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'payee': {'email_address': 'dummy-facilitator@dummy.dummy',
'merchant_id': 'G6R2B9YXADKWW'},
'description': 'Order JWJGC for PayPal v2',
'custom_id': 'Order PAYPALV2-JWJGC',
'soft_descriptor': 'MARTINFACIL',
'payments': {'captures': [{'id': '22A4162004478570J',
'status': 'PENDING',
'status_details': {
'reason': 'PENDING_REVIEW'
},
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'final_capture': True,
'disbursement_mode': 'INSTANT',
'seller_protection': {'status': 'ELIGIBLE',
'dispute_categories': [
'ITEM_NOT_RECEIVED',
'UNAUTHORIZED_TRANSACTION']},
'seller_receivable_breakdown': {
'gross_amount': {'currency_code': 'EUR',
'value': '43.59'},
'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'},
'net_amount': {'currency_code': 'EUR',
'value': '42.41'}},
'custom_id': 'Order PAYPALV2-JWJGC',
'links': [{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J',
'rel': 'self',
'method': 'GET'},
{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund',
'rel': 'refund',
'method': 'POST'},
{
'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'up',
'method': 'GET'}],
'create_time': '2022-04-28T12:00:22Z',
'update_time': '2022-04-28T12:00:22Z'}]}}],
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
'email_address': 'dummy@dummy.dummy',
'payer_id': 'Q739JNKWH67HE',
'address': {'country_code': 'DE'}},
'create_time': '2022-04-28T11:59:59Z',
'update_time': '2022-04-28T12:00:22Z',
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'self',
'method': 'GET'}]}
class Object():
pass
@@ -745,154 +690,3 @@ def test_webhook_refund2(env, client, monkeypatch):
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_pending_payment(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED)
pp_order = Result(get_test_order_review_pending())
mock_orders_get_request = MagicMock(return_value=pp_order)
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-0AH02875JL566842H-2AF03788XV8252724",
"create_time": "2022-04-28T12:00:37.077Z",
"resource_type": "capture",
"event_type": "PAYMENT.CAPTURE.PENDING",
"summary": "Payment pending for € 43.59 EUR",
"resource": {
"update_time": "2022-04-28T12:00:22Z",
"create_time": "2022-04-28T11:59:59Z",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"custom_id": "Order PAYPALV2-JWJGC",
"final_capture": True,
"id": "22A4162004478570J",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P",
"method": "GET",
"rel": "self"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P/refund",
"method": "POST",
"rel": "refund"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"method": "GET",
"rel": "up"
}
],
"payee": {
"email_address": "sb-ybfun52428692@business.example.com",
"merchant_id": "DLM8QKDR3CFZW"
},
"seller_protection": {
"status": "NOT_ELIGIBLE"
},
"status": "PENDING",
"status_details": {
"reason": "PENDING_REVIEW"
},
"supplementary_data": {
"related_ids": {
"order_id": "9L827155WD164573M"
}
}
},
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724",
"method": "GET",
"rel": "self"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724/resend",
"method": "POST",
"rel": "resend"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
with scopes_disabled():
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING
@pytest.mark.django_db
def test_webhook_capture_declined(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED)
pp_order = Result(get_test_order_review_pending())
mock_orders_get_request = MagicMock(return_value=pp_order)
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED
client.post('/_paypal/webhook/', json.dumps(
{
"create_time": "2026-08-17T12:23:30.687Z",
"event_type": "PAYMENT.CAPTURE.DECLINED",
"event_version": "1.0",
"id": "WH-XXXXXXXXXXXX-XXXXXXXXX",
"links": [
{
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX",
"method": "GET",
"rel": "self"
},
{
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX/resend",
"method": "POST",
"rel": "resend"
}
],
"resource": {
"amount": {},
"custom_id": "Order ABC-12345",
"disbursement_mode": "INSTANT",
"final_capture": True,
"id": "806440346Y391300T",
"payee": {},
"seller_protection": {},
"seller_receivable_breakdown": {},
"status": "DECLINED",
"supplementary_data": {}
},
"resource_type": "capture",
"resource_version": "2.0",
"summary": "A payment capture for € 30.0 EUR was declined."
}), content_type='application_json')
order = env[1]
order.refresh_from_db()
with scopes_disabled():
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_FAILED
+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='event__date_from', minutes=None, is_after=False)
RelativeDate(days=900, time=datetime.time(9, 0, 0), base_date_name='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='event__date_from', minutes=None)
RelativeDate(days=2, time=None, base_date_name='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='event__date_from', days=2, time=None, minutes=None
base_date_name='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,