Compare commits

..

13 Commits

Author SHA1 Message Date
Raphael Michel
ffd0612277 API: Add webhooks for customer events 2023-08-31 14:13:48 +02:00
Raphael Michel
53e84dfb08 API: Fix validation of duplicate customer email addresses 2023-08-30 16:57:15 +02:00
Raphael Michel
2e8447486c Improve edge cases in handling of check-in nonces (#3516) 2023-08-30 10:43:24 +02:00
robbi5
5b184bb1a0 Fix leaflet osm tile suggestion (#3549)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-08-30 09:53:41 +02:00
Ash So
8c6f0a5dc1 Translations: Update Chinese (Traditional)
Currently translated at 99.7% (5384 of 5400 strings)

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

powered by weblate
2023-08-30 09:46:11 +02:00
Alain
6a53091b91 Translations: Update Dutch
Currently translated at 84.4% (4559 of 5400 strings)

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

powered by weblate
2023-08-30 09:46:11 +02:00
Mira
be4bc9a6f3 TemplateBasedMailRenderer: make markdown compiler call overridable (#3550) 2023-08-30 09:41:34 +02:00
Raphael Michel
efb1141d59 PayPal: Add missing payment.fail() statements 2023-08-29 15:10:05 +02:00
Raphael Michel
322a730eb2 PayPal: Fix incorrect Decimal comparison 2023-08-29 15:06:03 +02:00
Raphael Michel
8d2224e725 API: Allow organizer-level access of orders and invoices (#3547) 2023-08-28 16:54:42 +02:00
Raphael Michel
5b819b76f0 Check-in: Fix N+1 query issue identified by sentry 2023-08-28 16:54:09 +02:00
Raphael Michel
5d90a42acf Discounts: Allow "buy X to get Y" with different product sets for X and Y (#3543) 2023-08-28 16:21:52 +02:00
Raphael Michel
5398671fde Fix crash in invoice address detection (PRETIXEU-8XE) 2023-08-28 11:45:30 +02:00
38 changed files with 1169 additions and 393 deletions

View File

@@ -31,9 +31,9 @@ subevent_mode strings Determines h
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
condition_all_products boolean If ``true``, the discount applies to all items.
condition_all_products boolean If ``true``, the discount condition applies to all items.
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
of internal item IDs that the discount applies to.
of internal item IDs that the discount condition applies to.
condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well,
otherwise it only applies to top-level items. The discount never
applies to bundled products.
@@ -48,6 +48,17 @@ benefit_discount_matching_percent decimal (string) The percenta
benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to
the cheapest matches. Useful for a "3 for 2"-style discount.
Cannot be combined with ``condition_min_value``.
benefit_same_products boolean If ``true``, the discount benefit applies to the same set of items
as the condition (see above).
benefit_limit_products list of integers If ``benefit_same_products`` is not set, this is a list
of internal item IDs that the discount benefit applies to.
benefit_apply_to_addons boolean (Only used if ``benefit_same_products`` is ``false``.)
If ``true``, the discount applies to add-on products as well,
otherwise it only applies to top-level items. The discount never
applies to bundled products.
benefit_ignore_voucher_discounted boolean (Only used if ``benefit_same_products`` is ``false``.)
If ``true``, the discount does not apply to products which have
been discounted by a voucher.
======================================== ========================== =======================================================
@@ -94,6 +105,10 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
@@ -146,6 +161,10 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
@@ -184,6 +203,10 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
@@ -211,6 +234,10 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
@@ -267,6 +294,10 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}

View File

@@ -12,6 +12,7 @@ The invoice resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
number string Invoice number (with prefix)
event string The slug of the parent event
order string Order code of the order this invoice belongs to
is_cancellation boolean ``true``, if this invoice is the cancellation of a
different invoice.
@@ -121,9 +122,13 @@ internal_reference string Customer's refe
The attribute ``lines.subevent`` has been added.
.. versionchanged:: 2023.8
Endpoints
---------
The ``event`` attribute has been added. The organizer-level endpoint has been added.
List of all invoices
--------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/
@@ -152,6 +157,7 @@ Endpoints
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -221,6 +227,50 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/invoices/
Returns a list of all invoices within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
...
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual invoices
----------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/
Returns information on one invoice, identified by its invoice number.
@@ -243,6 +293,7 @@ Endpoints
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -337,6 +388,12 @@ Endpoints
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Modifying invoices
------------------
Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one.

View File

@@ -20,6 +20,7 @@ The order resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
code string Order code
event string The slug of the parent event
status string Order status, one of:
* ``n`` pending
@@ -130,6 +131,10 @@ last_modified datetime Last modificati
The ``valid_if_pending`` attribute has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. _order-position-resource:
@@ -289,6 +294,7 @@ List of all orders
"results": [
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
@@ -441,6 +447,48 @@ List of all orders
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/orders/
Returns a list of all orders within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event,
with the exception that the ``pdf_data`` parameter is not supported here.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/orders/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"code": "ABC12",
"event": "sampleconf",
...
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual orders
--------------------------
@@ -466,6 +514,7 @@ Fetching individual orders
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",

View File

@@ -67,6 +67,9 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated``
* ``pretix.event.testmode.deactivated``
* ``pretix.customer.created``
* ``pretix.customer.changed``
* ``pretix.customer.anonymized``
Installed plugins might register more valid values.

View File

@@ -32,11 +32,13 @@ class DiscountSerializer(I18nAwareModelSerializer):
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'condition_ignore_voucher_discounted')
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data):
data = super().validate(data)

View File

@@ -284,11 +284,12 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
nonce = serializers.CharField(required=False, allow_null=True)
class Meta:
model = Checkin
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
'raw_subevent', 'datetime', 'type', 'position')
'raw_subevent', 'nonce', 'datetime', 'type', 'position')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -614,7 +615,7 @@ class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
'order': instance.order.code,
'secret': instance.order.secret,
'payment': instance.pk,
@@ -659,7 +660,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order):
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
'order': instance.code,
'secret': instance.secret,
})
@@ -694,6 +695,7 @@ class OrderListSerializer(serializers.ListSerializer):
class OrderSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True)
fees = OrderFeeSerializer(many=True, read_only=True)
@@ -709,7 +711,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
list_serializer_class = OrderListSerializer
fields = (
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending'
@@ -1593,6 +1595,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class InvoiceSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True)
lines = InlineInvoiceLineSerializer(many=True)
@@ -1601,7 +1604,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta:
model = Invoice
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',

View File

@@ -94,6 +94,14 @@ class CustomerSerializer(I18nAwareModelSerializer):
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
return data
def validate_email(self, value):
qs = Customer.objects.filter(organizer=self.context['organizer'], email__iexact=value)
if self.instance and self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(_("An account with this email address is already registered."))
return value
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)

View File

@@ -61,6 +61,8 @@ orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'orders', order.OrganizerOrderViewSet)
orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
team_router = routers.DefaultRouter()
@@ -77,7 +79,7 @@ event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')

View File

@@ -164,8 +164,21 @@ class CheckinListViewSet(viewsets.ModelViewSet):
secret=serializer.validated_data['raw_barcode']
).first()
clist = self.get_object()
if serializer.validated_data.get('nonce'):
if kwargs.get('position'):
prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
else:
prev = clist.checkins.filter(
nonce=serializer.validated_data['nonce'],
raw_barcode=serializer.validated_data['raw_barcode'],
).first()
if prev:
# Ignore because nonce is already handled
return Response(serializer.data, status=201)
c = serializer.save(
list=self.get_object(),
list=clist,
successful=False,
forced=True,
force_sent=True,

View File

@@ -44,6 +44,7 @@ from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
from rest_framework.mixins import CreateModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
@@ -185,7 +186,7 @@ with scopes_disabled():
)
class OrderViewSet(viewsets.ModelViewSet):
class OrderViewSetMixin:
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
@@ -193,19 +194,12 @@ class OrderViewSet(viewsets.ModelViewSet):
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
return ctx
def get_base_queryset(self):
raise NotImplementedError()
def get_queryset(self):
qs = self.request.event.orders
qs = self.get_base_queryset()
if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
@@ -227,11 +221,12 @@ class OrderViewSet(viewsets.ModelViewSet):
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if request.query_params.get('pdf_data', 'false') == 'true':
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
prefetch_related_objects([request.organizer], 'meta_properties')
prefetch_related_objects(
[request.event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'),
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questions',
'item_meta_properties',
)
@@ -266,13 +261,12 @@ class OrderViewSet(viewsets.ModelViewSet):
)
)
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
ctx['pdf_data'] = False
return ctx
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
@@ -289,6 +283,45 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
def get_base_queryset(self):
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if isinstance(self.request.auth, (TeamAPIToken, Device)):
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm)
)
elif self.request.user.is_authenticated:
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm)
)
else:
raise PermissionDenied()
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
return ctx
def get_base_queryset(self):
return self.request.event.orders
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
@@ -1782,11 +1815,24 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
write_permission = 'can_change_orders'
def get_queryset(self):
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if getattr(self.request, 'event', None):
qs = self.request.event.invoices
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm)
)
elif self.request.user.is_authenticated:
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm)
)
return qs.prefetch_related('lines').select_related('order', 'refers').annotate(
nr=Concat('prefix', 'invoice_no')
)
@action(detail=True, )
@action(detail=True)
def download(self, request, **kwargs):
invoice = self.get_object()
@@ -1805,7 +1851,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return resp
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwarts):
def regenerate(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
@@ -1815,7 +1861,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer:
raise PermissionDenied('The invoice file has already been exported.')
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
elif now().astimezone(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1):
raise PermissionDenied('The invoice file is too old to be regenerated.')
else:
inv = regenerate_invoice(inv)
@@ -1830,7 +1876,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=204)
@action(detail=True, methods=['POST'])
def reissue(self, request, **kwarts):
def reissue(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')

View File

@@ -202,6 +202,21 @@ class ParametrizedWaitingListEntryWebhookEvent(ParametrizedWebhookEvent):
}
class ParametrizedCustomerWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
customer = logentry.content_object
if not customer:
return None
return {
'notification_id': logentry.pk,
'organizer': customer.organizer.slug,
'customer': customer.identifier,
'action': logentry.action_type,
}
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
def register_default_webhook_events(sender, **kwargs):
return (
@@ -350,6 +365,18 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.orders.waitinglist.voucher_assigned',
_('Waiting list entry received voucher'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.created',
_('Customer account created'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.changed',
_('Customer account changed'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.anonymized',
_('Customer account anonymized'),
),
)

View File

@@ -134,8 +134,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self):
raise NotImplementedError()
def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
body_md = markdown_compile_email(plain_body)
body_md = self.compile_markdown(plain_body)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
@@ -153,7 +156,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if plain_signature:
signature_md = plain_signature.replace('\n', '<br>\n')
signature_md = markdown_compile_email(signature_md)
signature_md = self.compile_markdown(signature_md)
htmlctx['signature'] = signature_md
if order:

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.2.4 on 2023-08-28 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0244_mediumkeyset"),
]
operations = [
migrations.AddField(
model_name="discount",
name="benefit_apply_to_addons",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="discount",
name="benefit_ignore_voucher_discounted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="discount",
name="benefit_limit_products",
field=models.ManyToManyField(
related_name="benefit_discounts", to="pretixbase.item"
),
),
migrations.AddField(
model_name="discount",
name="benefit_same_products",
field=models.BooleanField(default=True),
),
]

View File

@@ -99,7 +99,7 @@ class Discount(LoggedModel):
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
verbose_name=_("Count add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
condition_ignore_voucher_discounted = models.BooleanField(
@@ -107,7 +107,7 @@ class Discount(LoggedModel):
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be considered for this discount. However, products that use a voucher only to e.g. unlock a "
"hidden product or gain access to sold-out quota will still receive the discount."),
"hidden product or gain access to sold-out quota will still be considered."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
@@ -120,6 +120,19 @@ class Discount(LoggedModel):
default=Decimal('0.00'),
)
benefit_same_products = models.BooleanField(
default=True,
verbose_name=_("Apply discount to same set of products"),
help_text=_("By default, the discount is applied across the same selection of products than the condition for "
"the discount given above. If you want, you can however also select a different selection of "
"products.")
)
benefit_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply discount to specific products"),
related_name='benefit_discounts',
blank=True
)
benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'),
decimal_places=2,
@@ -139,6 +152,18 @@ class Discount(LoggedModel):
blank=True,
validators=[MinValueValidator(1)],
)
benefit_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
benefit_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be discounted. However, products that use a voucher only to e.g. unlock a hidden product or gain "
"access to sold-out quota will still receive the discount."),
)
# more feature ideas:
# - max_usages_per_order
@@ -187,6 +212,14 @@ class Discount(LoggedModel):
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and not data.get('benefit_same_products'):
raise ValidationError(
{'benefit_same_products': [
_('You cannot apply the discount to a different set of products if the discount is only valid '
'for bookings of different dates.')
]}
)
def allow_delete(self):
return not self.orderposition_set.exists()
@@ -197,6 +230,7 @@ class Discount(LoggedModel):
'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
'benefit_same_products': self.benefit_same_products,
})
def is_available_by_time(self, now_dt=None) -> bool:
@@ -207,14 +241,14 @@ class Discount(LoggedModel):
return False
return True
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in idx_group:
for idx in benefit_idx_group:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
@@ -222,8 +256,8 @@ class Discount(LoggedModel):
)
result[idx] = new_price
def _apply_min_count(self, positions, idx_group, result):
if len(idx_group) < self.condition_min_count:
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
if len(condition_idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
@@ -233,15 +267,17 @@ class Discount(LoggedModel):
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count]
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches]
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
else:
consume_idx = idx_group
benefit_idx = idx_group
consume_idx = condition_idx_group
benefit_idx = benefit_idx_group
for idx in benefit_idx:
previous_price = positions[idx][2]
@@ -276,7 +312,7 @@ class Discount(LoggedModel):
limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope
initial_candidates = [
condition_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
@@ -286,11 +322,25 @@ class Discount(LoggedModel):
)
]
if self.benefit_same_products:
benefit_candidates = list(condition_candidates)
else:
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, initial_candidates, result)
self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
else:
self._apply_min_value(positions, initial_candidates, result)
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
@@ -299,17 +349,18 @@ class Discount(LoggedModel):
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(initial_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups]
_groups = groupby(sorted(condition_candidates, key=key), key=key)
candidate_groups = [(k, list(g)) for k, g in _groups]
for g in candidate_groups:
for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
if self.condition_min_count:
self._apply_min_count(positions, g, result)
self._apply_min_count(positions, g, benefit_g, result)
else:
self._apply_min_value(positions, g, result)
self._apply_min_value(positions, g, benefit_g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value:
if self.condition_min_value or not self.benefit_same_products:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
@@ -336,7 +387,7 @@ class Discount(LoggedModel):
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll in initial_candidates and ll not in current_group]
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
@@ -373,5 +424,5 @@ class Discount(LoggedModel):
break
for g in candidate_groups:
self._apply_min_count(positions, g, result)
self._apply_min_count(positions, g, g, result)
return result

View File

@@ -907,14 +907,18 @@ class Event(EventMixin, LoggedModel):
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'):
items = list(d.condition_limit_products.all())
c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
d.pk = None
d.event = self
d.save(force_insert=True)
d.log_action('pretix.object.cloned')
for i in items:
for i in c_items:
if i.pk in item_map:
d.condition_limit_products.add(item_map[i.pk])
for i in b_items:
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):

View File

@@ -336,12 +336,6 @@ class BasePaymentProvider:
help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False,
)),
('_availability_start',
RelativeDateField(
label=_('Available from'),
help_text=_('Users will not be able to choose this payment provider before the given date.'),
required=False,
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
@@ -545,57 +539,40 @@ class BasePaymentProvider:
return form
def _convert_availability_date_to_absolute(self, rel_date, cart_id=None, order=None):
if not rel_date:
return None
# In an event series, we use min() here, which makes it less restrictive than max() and thus makes
# it harder to put one self into a situation where no payment provider is available.
if self.event.has_subevents and cart_id:
dates = [
rel_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=CartPosition.objects.filter(
cart_id=cart_id, event=self.event
).values_list('subevent', flat=True)
)
]
return min(dates) if dates else None
elif self.event.has_subevents and order:
dates = [
rel_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=order.positions.values_list('subevent', flat=True)
)
]
return min(dates) if dates else None
elif self.event.has_subevents:
raise NotImplementedError('Payment provider is not subevent-ready.')
else:
return rel_date.datetime(self.event).date()
def _is_available_by_time(self, now_dt=None, cart_id=None, order=None):
def _is_still_available(self, now_dt=None, cart_id=None, order=None):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
try:
availability_start = self._convert_availability_date_to_absolute(
self.settings.get('_availability_start', as_type=RelativeDateWrapper), cart_id, order)
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
if availability_date:
if self.event.has_subevents and cart_id:
dates = [
availability_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=CartPosition.objects.filter(
cart_id=cart_id, event=self.event
).values_list('subevent', flat=True)
)
]
availability_date = min(dates) if dates else None
elif self.event.has_subevents and order:
dates = [
availability_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=order.positions.values_list('subevent', flat=True)
)
]
availability_date = min(dates) if dates else None
elif self.event.has_subevents:
logger.error('Payment provider is not subevent-ready.')
return False
else:
availability_date = availability_date.datetime(self.event).date()
if availability_start:
if availability_start > now_dt.astimezone(tz).date():
return False
if availability_date:
return availability_date >= now_dt.astimezone(tz).date()
availability_end = self._convert_availability_date_to_absolute(
self.settings.get('_availability_date', as_type=RelativeDateWrapper), cart_id, order)
if availability_end:
if availability_end < now_dt.astimezone(tz).date():
return False
return True
except NotImplementedError:
logger.exception('Unable to check availability')
return False
return True
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
"""
@@ -604,9 +581,9 @@ class BasePaymentProvider:
user will not be able to select this payment method. This will only be called
during checkout, not on retrying.
The default implementation checks for the ``_availability_date`` setting to be either unset or in the future
and for the ``_availability_from``, ``_total_max``, and ``_total_min`` requirements to be met. It also checks
the ``_restrict_countries`` and ``_restrict_to_sales_channels`` setting.
The default implementation checks for the _availability_date setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
and ``_restrict_to_sales_channels`` setting.
:param total: The total value without the payment method fee, after taxes.
@@ -615,7 +592,7 @@ class BasePaymentProvider:
The ``total`` parameter has been added. For backwards compatibility, this method is called again
without this parameter if it raises a ``TypeError`` on first try.
"""
timing = self._is_available_by_time(cart_id=get_or_create_cart_id(request))
timing = self._is_still_available(cart_id=get_or_create_cart_id(request))
pricing = True
if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None:
@@ -799,8 +776,8 @@ class BasePaymentProvider:
Will be called to check whether it is allowed to change the payment method of
an order to this one.
The default implementation checks for the ``_availability_date`` setting to be either unset or in the future,
as well as for the ``_availabilty_from``, ``_total_max``, ``_total_min``, and ``_restricted_countries`` settings.
The default implementation checks for the _availability_date setting to be either unset or in the future,
as well as for the _total_max, _total_min and _restricted_countries settings.
:param order: The order object
"""
@@ -827,7 +804,7 @@ class BasePaymentProvider:
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False
return self._is_available_by_time(order=order)
return self._is_still_available(order=order)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
"""

View File

@@ -886,7 +886,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if isinstance(auth, Device):
device = auth
last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce'))
last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce', 'position_id'))
entry_allowed = (
type == Checkin.TYPE_EXIT or
clist.allow_multiple_entries or

View File

@@ -171,7 +171,7 @@ def apply_discounts(event: Event, sales_channel: str,
Q(available_until__isnull=True) | Q(available_until__gte=now()),
sales_channels__contains=sales_channel,
active=True,
).prefetch_related('condition_limit_products').order_by('position', 'pk')
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)

View File

@@ -292,7 +292,7 @@ class LinkifyAndCleanExtension(Extension):
)
def markdown_compile_email(source):
def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
@@ -306,8 +306,8 @@ def markdown_compile_email(source):
EmailNl2BrExtension(),
LinkifyAndCleanExtension(
linker,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
tags=allowed_tags,
attributes=allowed_attributes,
protocols=ALLOWED_PROTOCOLS,
strip=False,
)

View File

@@ -50,11 +50,16 @@ class DiscountForm(I18nModelForm):
'condition_ignore_voucher_discounted',
'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches',
'benefit_same_products',
'benefit_limit_products',
'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -64,11 +69,14 @@ class DiscountForm(I18nModelForm):
'data-inverse-dependency': '<[name$=all_products]',
'class': 'scrolling-multiple-choice',
}),
'benefit_limit_products': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice',
}),
'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput(
attrs={
'data-display-dependency': '#id_condition_min_count',
}
)
),
}
def __init__(self, *args, **kwargs):
@@ -85,6 +93,7 @@ class DiscountForm(I18nModelForm):
widget=forms.CheckboxSelectMultiple,
)
self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['benefit_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False
self.fields['condition_min_count'].widget.is_required = False
self.fields['condition_min_value'].required = False

View File

@@ -86,12 +86,14 @@ class GlobalSettingsForm(SettingsForm):
('leaflet_tiles', forms.CharField(
required=False,
label=_("Leaflet tiles URL pattern"),
help_text=_("e.g. {sample}").format(sample="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png")
help_text=_("e.g. {sample}").format(sample="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
)),
('leaflet_tiles_attribution', forms.CharField(
required=False,
label=_("Leaflet tiles attribution"),
help_text=_("e.g. {sample}").format(sample='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors')
help_text=_("e.g. {sample}").format(
sample='&copy; &lt;a href=&quot;https://www.openstreetmap.org/copyright&quot;&gt;OpenStreetMap&lt;/a&gt; contributors'
)
)),
])
responses = register_global_settings.send(self)

View File

@@ -48,6 +48,12 @@
</fieldset>
<fieldset>
<legend>{% trans "Benefit" context "discount" %}</legend>
{% bootstrap_field form.benefit_same_products layout="control" %}
<div data-display-dependency="#id_benefit_same_products" data-inverse>
{% bootstrap_field form.benefit_limit_products layout="control" %}
{% bootstrap_field form.benefit_apply_to_addons layout="control" %}
{% bootstrap_field form.benefit_ignore_voucher_discounted layout="control" %}
</div>
{% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %}
{% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %}
</fieldset>

View File

@@ -7,7 +7,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-07-27 11:49+0000\n"
"PO-Revision-Date: 2023-08-24 04:00+0000\n"
"PO-Revision-Date: 2023-08-25 04:00+0000\n"
"Last-Translator: Alain <alain@waag.org>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/>"
"\n"
@@ -411,7 +411,7 @@ msgstr "Bestelling is verlopen"
#: pretix/api/webhooks.py:234
msgid "Order expiry date changed"
msgstr "Verloopdatum aangepast."
msgstr "Verloopdatum aangepast"
#: pretix/api/webhooks.py:238 pretix/base/notifications.py:269
msgid "Order information changed"
@@ -7045,38 +7045,30 @@ msgstr ""
"door u gekozen hoeveelheid. Zie hieronder voor de details."
#: pretix/base/services/cart.py:114
#, fuzzy, python-format
#| msgid "You cannot select more than %s items per order."
#, python-format
msgid "You cannot select more than %s item per order."
msgid_plural "You cannot select more than %s items per order."
msgstr[0] "U kunt niet meer dan %s items per bestelling kiezen."
msgstr[0] "U kunt niet meer dan %s item per bestelling kiezen."
msgstr[1] "U kunt niet meer dan %s items per bestelling kiezen."
#: pretix/base/services/cart.py:118 pretix/base/services/orders.py:1468
#, fuzzy, python-format
#| msgid ""
#| "You cannot select more than %(max)s items of the product %(product)s."
#, python-format
msgid "You cannot select more than %(max)s item of the product %(product)s."
msgid_plural ""
"You cannot select more than %(max)s items of the product %(product)s."
msgstr[0] "U kunt niet meer dan %(max)s items van product %(product)s kiezen."
msgstr[0] "U kunt niet meer dan %(max)s item van product %(product)s kiezen."
msgstr[1] "U kunt niet meer dan %(max)s items van product %(product)s kiezen."
#: pretix/base/services/cart.py:123 pretix/base/services/orders.py:1473
#, fuzzy, python-format
#| msgid ""
#| "You need to select at least %(min)s items of the product %(product)s."
#, python-format
msgid "You need to select at least %(min)s item of the product %(product)s."
msgid_plural ""
"You need to select at least %(min)s items of the product %(product)s."
msgstr[0] "U moet ten minste %(min)s items van product %(product)s kiezen."
msgstr[0] "U moet ten minste %(min)s item van product %(product)s kiezen."
msgstr[1] "U moet ten minste %(min)s items van product %(product)s kiezen."
#: pretix/base/services/cart.py:128
#, fuzzy, python-format
#| msgid ""
#| "We removed %(product)s from your cart as you can not buy less than "
#| "%(min)s items of it."
#, python-format
msgid ""
"We removed %(product)s from your cart as you can not buy less than %(min)s "
"item of it."
@@ -7085,10 +7077,10 @@ msgid_plural ""
"items of it."
msgstr[0] ""
"We hebben %(product)s uit uw winkelwagen verwijderd, omdat u niet minder dan "
"%(min)s ervan kunt kopen."
"%(min)s item ervan kunt kopen."
msgstr[1] ""
"We hebben %(product)s uit uw winkelwagen verwijderd, omdat u niet minder dan "
"%(min)s ervan kunt kopen."
"%(min)s items ervan kunt kopen."
#: pretix/base/services/cart.py:132 pretix/base/services/orders.py:146
#: pretix/presale/templates/pretixpresale/event/index.html:157
@@ -7250,10 +7242,7 @@ msgid "You can not select two variations of the same add-on product."
msgstr "U kunt niet twee varianten van hetzelfde add-on-product selecteren."
#: pretix/base/services/cart.py:185 pretix/base/services/orders.py:184
#, fuzzy, python-format
#| msgid ""
#| "You can select at most %(max)s add-ons from the category %(cat)s for the "
#| "product %(base)s."
#, python-format
msgid ""
"You can select at most %(max)s add-on from the category %(cat)s for the "
"product %(base)s."
@@ -7261,17 +7250,14 @@ msgid_plural ""
"You can select at most %(max)s add-ons from the category %(cat)s for the "
"product %(base)s."
msgstr[0] ""
"U kunt maximaal %(max)s add-ons van de categorie %(cat)s selecteren voor het "
"U kunt maximaal %(max)s add-on van de categorie %(cat)s selecteren voor het "
"product %(base)s."
msgstr[1] ""
"U kunt maximaal %(max)s add-ons van de categorie %(cat)s selecteren voor het "
"product %(base)s."
#: pretix/base/services/cart.py:190 pretix/base/services/orders.py:189
#, fuzzy, python-format
#| msgid ""
#| "You need to select at least %(min)s add-ons from the category %(cat)s for "
#| "the product %(base)s."
#, python-format
msgid ""
"You need to select at least %(min)s add-on from the category %(cat)s for the "
"product %(base)s."
@@ -7279,7 +7265,7 @@ msgid_plural ""
"You need to select at least %(min)s add-ons from the category %(cat)s for "
"the product %(base)s."
msgstr[0] ""
"U moet minimaal %(min)s add-ons van de categorie %(cat)s selecteren voor het "
"U moet minimaal %(min)s add-on van de categorie %(cat)s selecteren voor het "
"product %(base)s."
msgstr[1] ""
"U moet minimaal %(min)s add-ons van de categorie %(cat)s selecteren voor het "
@@ -7444,16 +7430,14 @@ msgid "This ticket has been blocked."
msgstr "Dit ticket werd reeds eenmaal gebruikt."
#: pretix/base/services/checkin.py:781 pretix/base/services/checkin.py:785
#, fuzzy, python-brace-format
#| msgid "Only allowed after {datetime}"
#, python-brace-format
msgid "This ticket is only valid after {datetime}."
msgstr "Alleen toegestaan vanaf {datetime}"
msgstr "Dit ticket is geldig vanaf {datetime}."
#: pretix/base/services/checkin.py:795 pretix/base/services/checkin.py:799
#, fuzzy, python-brace-format
#| msgid "This ticket has already been redeemed."
#, python-brace-format
msgid "This ticket was only valid before {datetime}."
msgstr "Dit ticket is al gebruikt."
msgstr "Dit ticket was geldig vòòr {datetime}."
#: pretix/base/services/checkin.py:830
msgid "This order position has an invalid product for this check-in list."
@@ -7514,10 +7498,8 @@ msgid "Export failed"
msgstr "Geëxporteerde bestanden"
#: pretix/base/services/export.py:206
#, fuzzy
#| msgid "Permission denied"
msgid "Permission denied."
msgstr "Geen toestemming"
msgstr "Geen toestemming."
#: pretix/base/services/export.py:221
msgid "Your exported data exceeded the size limit for scheduled exports."
@@ -7548,11 +7530,10 @@ msgstr ""
"{country}"
#: pretix/base/services/invoices.py:220 pretix/base/services/invoices.py:257
#, fuzzy, python-brace-format
#| msgid "Event location"
#, python-brace-format
msgctxt "invoice"
msgid "Event location: {location}"
msgstr "Evenementlocatie"
msgstr "Evenementlocatie: {location}"
#: pretix/base/services/invoices.py:236
#, python-brace-format

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-07-27 11:49+0000\n"
"PO-Revision-Date: 2023-06-28 06:00+0000\n"
"Last-Translator: Yucheng Lin <yuchenglinedu@gmail.com>\n"
"PO-Revision-Date: 2023-08-30 07:00+0000\n"
"Last-Translator: Ash So <ashs@vankaifong.com>\n"
"Language-Team: Chinese (Traditional) <https://translate.pretix.eu/projects/"
"pretix/pretix/zh_Hant/>\n"
"Language: zh_Hant\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 4.17\n"
"X-Generator: Weblate 4.18.2\n"
#: pretix/_base_settings.py:78
msgid "English"
@@ -5876,24 +5876,18 @@ msgid "Ambiguous option selected."
msgstr "選擇不明確的選項。"
#: pretix/base/orderimport.py:845
#, fuzzy
#| msgid "No matching seat was found."
msgid "No matching customer was found."
msgstr "未找到符合的座位。"
msgstr "未找到符合的客戶。"
#: pretix/base/payment.py:86
#, fuzzy
#| msgid "Apply"
msgctxt "payment"
msgid "Apple Pay"
msgstr "應用"
msgstr "Apple Pay"
#: pretix/base/payment.py:87
#, fuzzy
#| msgid "Android (Google Play)"
msgctxt "payment"
msgid "Google Pay"
msgstr "安卓Google Play"
msgstr "安卓Google Pay"
#: pretix/base/payment.py:256
#: pretix/presale/templates/pretixpresale/event/order.html:115
@@ -6424,16 +6418,12 @@ msgid "List of Add-Ons"
msgstr "附加組件清單"
#: pretix/base/pdf.py:364
#, fuzzy
#| msgid ""
#| "Add-on 1\n"
#| "Add-on 2"
msgid ""
"Add-on 1\n"
"2x Add-on 2"
msgstr ""
"附加1\n"
"附加2"
"2x附加2"
#: pretix/base/pdf.py:370 pretix/control/forms/filter.py:1275
#: pretix/control/forms/filter.py:1277
@@ -9410,25 +9400,12 @@ msgstr ""
"你的{event} 團隊"
#: pretix/base/settings.py:2349
#, fuzzy, python-brace-format
#| msgid "Payment received for your order: {code}"
#, python-brace-format
msgid "Payment failed for your order: {code}"
msgstr "收到的訂單付款:{code}"
msgstr "訂單付款失敗{code}"
#: pretix/base/settings.py:2353
#, fuzzy, python-brace-format
#| msgid ""
#| "Hello,\n"
#| "\n"
#| "we did not yet receive a full payment for your order for {event}.\n"
#| "Please keep in mind that we only guarantee your order if we receive\n"
#| "your payment before {expire_date}.\n"
#| "\n"
#| "You can view the payment information and the status of your order at\n"
#| "{url}\n"
#| "\n"
#| "Best regards, \n"
#| "Your {event} team"
#, python-brace-format
msgid ""
"Hello,\n"
"\n"
@@ -9446,11 +9423,12 @@ msgid ""
msgstr ""
"你好\n"
"\n"
"我們尚未收到你的 {event} 訂單的全額付款。\n"
"請記住,我們僅在收到時保證你的訂單\n"
"你在 {expire_date} 之前的付款。\n"
"你在 {event} 訂單付款未能成功。\n"
"\n"
"你可以在以下位置查看付款資訊與訂單狀態:\n"
"您的訂單仍然有效,您可以嘗試使用相同或不同的付款方式再次進行支付,唯請在 "
"{expire_date} 前完成付款程序。\n"
"\n"
"您可以重新嘗試付款,並在以下網址檢視您的訂單狀態:\n"
"{url}\n"
"\n"
"敬此\n"
@@ -13568,7 +13546,7 @@ msgstr "已為位置 #{posid} 生成一個新密鑰。"
#, python-brace-format
msgid ""
"The validity start date for position #{posid} has been changed to {value}."
msgstr "位置 #{posid} 的有效開始日期已更改為{value}"
msgstr "位置 #{posid} 的有效開始日期已更改為{value}"
#: pretix/control/logdisplay.py:171
#, python-brace-format
@@ -13846,10 +13824,8 @@ msgid "The medium has been connected to a new ticket."
msgstr "媒體已連接到新票證。"
#: pretix/control/logdisplay.py:371
#, fuzzy
#| msgid "The medium has been connected to a new ticket."
msgid "The medium has been connected to a new gift card."
msgstr "媒體已連接到新票證。"
msgstr "媒體已連接到新的禮品卡。"
#: pretix/control/logdisplay.py:372 pretix/control/logdisplay.py:413
msgid "Sending of an email has failed."
@@ -14085,11 +14061,8 @@ msgid ""
msgstr "包含訂單詳細資訊頁面連結的電子郵件已重新發送給使用者。"
#: pretix/control/logdisplay.py:436
#, fuzzy
#| msgid ""
#| "An email has been sent to notify the user that payment has been received."
msgid "An email has been sent to notify the user that the payment failed."
msgstr "已發送一封電子郵件通知使用者已收到付款。"
msgstr "已發送一封電子郵件通知使用者未能成功付款。"
#: pretix/control/logdisplay.py:437
#, python-brace-format
@@ -16788,10 +16761,8 @@ msgid "Payment reminder"
msgstr "付款提醒"
#: pretix/control/templates/pretixcontrol/event/mail.html:108
#, fuzzy
#| msgid "Payment fee"
msgid "Payment failed"
msgstr "支付費用"
msgstr "支付失敗"
#: pretix/control/templates/pretixcontrol/event/mail.html:111
msgid "Waiting list notification"
@@ -16845,8 +16816,6 @@ msgid "Deadlines"
msgstr "期限"
#: pretix/control/templates/pretixcontrol/event/payment.html:68
#, fuzzy
#| msgid "days"
msgctxt "unit"
msgid "days"
msgstr "日"
@@ -21981,11 +21950,11 @@ msgstr "兩步驟狀態"
#: pretix/control/templates/pretixcontrol/user/2fa_main.html:44
msgid "Two-factor authentication is currently enabled."
msgstr "兩步驟驗證目前啟用"
msgstr "兩步驟驗證目前啟用"
#: pretix/control/templates/pretixcontrol/user/2fa_main.html:60
msgid "Two-factor authentication is currently disabled."
msgstr "兩步驟驗證目前停用"
msgstr "兩步驟驗證目前停用"
#: pretix/control/templates/pretixcontrol/user/2fa_main.html:63
msgid "To enable it, you need to configure at least one device below."
@@ -22529,20 +22498,14 @@ msgstr "此條目的優先順序已修改。此數字越高,此人將越早獲
msgid ""
"For safety reasons, the waiting list does not run if the quota is set to "
"unlimited."
msgstr ""
msgstr "出於安全考慮,如果額度設定為無限制,將不設等候名單。"
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:219
#, fuzzy
#| msgid "Quota name"
msgid "Quota unlimited"
msgstr "額度名稱"
msgstr "無限額度"
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:225
#, fuzzy, python-format
#| msgid ""
#| "\n"
#| " Waiting, product %(num)sx available\n"
#| " "
#, python-format
msgid ""
"\n"
" Waiting, product %(num)sx "
@@ -22550,8 +22513,8 @@ msgid ""
" "
msgstr ""
"\n"
" 等待中,產品 %(num)s可用\n"
" "
" 等待中,產品 %(num)s可用\n"
" "
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:231
msgid "Waiting, product unavailable"
@@ -23678,7 +23641,7 @@ msgstr "訂單已更改,使用者已收到通知。"
#: pretix/control/views/orders.py:1828 pretix/control/views/orders.py:1962
#: pretix/control/views/orders.py:1999 pretix/presale/views/order.py:1538
msgid "The order has been changed."
msgstr "訂單順序已更改"
msgstr "訂單順序已更改"
#: pretix/control/views/orders.py:1855 pretix/presale/checkoutflow.py:881
#: pretix/presale/views/order.py:799
@@ -25274,7 +25237,7 @@ msgstr "值機清單 PDF"
#: pretix/plugins/checkinlists/exporters.py:648
msgctxt "export_category"
msgid "Check-in"
msgstr "Check-in"
msgstr "簽到"
#: pretix/plugins/checkinlists/exporters.py:286
msgid ""
@@ -26107,9 +26070,6 @@ msgid "Restrict to event dates starting before"
msgstr "限制為早於之前開始的活動日期"
#: pretix/plugins/sendmail/forms.py:170
#, fuzzy
#| msgctxt "sendmail_from"
#| msgid "Send to"
msgctxt "sendmail_form"
msgid "Send to"
msgstr "傳送到"
@@ -26124,9 +26084,6 @@ msgid "Filter check-in status"
msgstr "篩選簽到狀態"
#: pretix/plugins/sendmail/forms.py:189
#, fuzzy
#| msgctxt "sendmail_from"
#| msgid "Restrict to recipients without check-in"
msgctxt "sendmail_form"
msgid "Restrict to recipients without check-in"
msgstr "僅限未簽到的收件者"
@@ -26176,17 +26133,11 @@ msgid "pending with payment overdue"
msgstr "待處理,付款逾期"
#: pretix/plugins/sendmail/forms.py:258
#, fuzzy
#| msgctxt "sendmail_from"
#| msgid "Restrict to orders with status"
msgctxt "sendmail_form"
msgid "Restrict to orders with status"
msgstr "限制為具有狀態的訂單"
msgstr "限具有狀態的訂單"
#: pretix/plugins/sendmail/forms.py:283 pretix/plugins/sendmail/forms.py:287
#, fuzzy
#| msgctxt "sendmail_from"
#| msgid "Restrict to recipients with check-in on list"
msgctxt "sendmail_form"
msgid "Restrict to recipients with check-in on list"
msgstr "僅限在清單中簽到的收件者"
@@ -26257,11 +26208,8 @@ msgid "Limit products"
msgstr "限制商品"
#: pretix/plugins/sendmail/models.py:218
#, fuzzy
#| msgctxt "sendmail_from"
#| msgid "Restrict to orders with status"
msgid "Restrict to orders with status"
msgstr "限制為具有狀態的訂單"
msgstr "限具有狀態的訂單"
#: pretix/plugins/sendmail/models.py:228
msgid "Send date"
@@ -26840,10 +26788,8 @@ msgid "Bancontact"
msgstr "Bancontact"
#: pretix/plugins/stripe/payment.py:357
#, fuzzy
#| msgid "Disable SEPA Direct Debit"
msgid "SEPA Direct Debit"
msgstr "禁用 SEPA 直接扣款"
msgstr "SEPA 直接扣款"
#: pretix/plugins/stripe/payment.py:362
#, fuzzy
@@ -26975,26 +26921,20 @@ msgid "Credit card"
msgstr "信用卡"
#: pretix/plugins/stripe/payment.py:1157
#, fuzzy
#| msgid "EPS via Stripe"
msgid "SEPA Debit via Stripe"
msgstr "EPS透過Stripe"
msgstr "透過Stripe進行SEPA直接扣款"
#: pretix/plugins/stripe/payment.py:1158
msgid "SEPA Debit"
msgstr ""
msgstr "SEPA扣款"
#: pretix/plugins/stripe/payment.py:1197
#, fuzzy
#| msgid "Account holder"
msgid "Account Holder Name"
msgstr "帳戶持有人"
msgstr "帳戶持有人名稱"
#: pretix/plugins/stripe/payment.py:1202
#, fuzzy
#| msgid "Account holder"
msgid "Account Holder Street"
msgstr "帳戶持有人"
msgstr "帳戶持有人街道"
#: pretix/plugins/stripe/payment.py:1214
#, fuzzy
@@ -27003,16 +26943,12 @@ msgid "Account Holder Postal Code"
msgstr "帳戶持有人"
#: pretix/plugins/stripe/payment.py:1226
#, fuzzy
#| msgid "Account holder"
msgid "Account Holder City"
msgstr "帳戶持有人"
msgstr "帳戶持有人城市"
#: pretix/plugins/stripe/payment.py:1238
#, fuzzy
#| msgid "Account holder"
msgid "Account Holder Country"
msgstr "帳戶持有人"
msgstr "帳戶持有人國家"
#: pretix/plugins/stripe/payment.py:1282
msgid "giropay via Stripe"
@@ -27183,10 +27119,8 @@ msgid "Card type"
msgstr "卡片類型"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html:14
#, fuzzy
#| msgid "The total amount will be withdrawn from your credit card."
msgid "The total amount will be withdrawn from your bank account."
msgstr "總金額將從你的信用卡中提取。"
msgstr "總金額將從你的銀行戶口中提取。"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html:18
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html:20
@@ -27195,10 +27129,8 @@ msgstr ""
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html:20
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html:22
#, fuzzy
#| msgid "Account holder"
msgid "Account number"
msgstr "帳戶持有人"
msgstr "帳戶號碼"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html:24
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple.html:4
@@ -27246,20 +27178,14 @@ msgid "For a SEPA Debit payment, please turn on JavaScript."
msgstr "對於信用卡付款請打開JavaScript。"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html:16
#, fuzzy
#| msgid ""
#| "You already entered a card number that we will use to charge the payment "
#| "amount."
msgid ""
"You already entered a bank account that we will use to charge the payment "
"amount."
msgstr "已經輸入了一個卡號,我們將使用該卡號來收取付款金額。"
msgstr "已經輸入了一個我們將用來扣除支付金額的銀行帳戶。"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html:27
#, fuzzy
#| msgid "Use a different card"
msgid "Use a different account"
msgstr "使用不同卡片"
msgstr "使用不同帳戶"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html:51
#, python-format
@@ -27539,7 +27465,7 @@ msgstr "網上check-in"
#: pretix/plugins/webcheckin/templates/pretixplugins/webcheckin/index.html:10
msgid "Check-in"
msgstr "Check-in"
msgstr "簽到"
#: pretix/presale/checkoutflow.py:107
msgctxt "checkoutflow"

View File

@@ -607,6 +607,12 @@ class PaypalMethod(BasePaymentProvider):
response = self.client.execute(req)
except IOError as e:
logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
payment.fail(info={
"error": {
"name": "IOError",
"message": str(e),
}
})
raise PaymentException(_('We had trouble communicating with PayPal'))
else:
pp_captured_order = response.result
@@ -615,9 +621,15 @@ class PaypalMethod(BasePaymentProvider):
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id)
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
if str(pp_captured_order.purchase_units[0].amount.value) != str(payment.amount) or \
if Decimal(pp_captured_order.purchase_units[0].amount.value) != payment.amount or \
pp_captured_order.purchase_units[0].amount.currency_code != self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_captured_order.dict())))
payment.fail(info={
"error": {
"name": "ValidationError",
"message": "Value mismatch",
}
})
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
@@ -660,6 +672,12 @@ class PaypalMethod(BasePaymentProvider):
self.client.execute(patchreq)
except IOError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
payment.fail(info={
"error": {
"name": "IOError",
"message": str(e),
}
})
logger.exception('PayPal OrdersPatchRequest: {}'.format(str(e)))
return

View File

@@ -554,6 +554,9 @@ class StripeMethod(BasePaymentProvider):
ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'provider': self}
return template.render(ctx)
def payment_can_retry(self, payment):
return self._is_still_available(order=payment.order)
def _charge_source(self, request, source, payment):
try:
params = {}
@@ -1578,6 +1581,9 @@ class StripeSofort(StripeMethod):
return True
return False
def payment_can_retry(self, payment):
return payment.state != OrderPayment.PAYMENT_STATE_PENDING and self._is_still_available(order=payment.order)
def payment_presale_render(self, payment: OrderPayment) -> str:
pi = payment.info_data or {}
try:

View File

@@ -1038,7 +1038,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def reduce_initial(v):
if isinstance(v, dict):
# try to flatten objects such as name_parts to a single string to determine whether they have any value set
return ''.join([v for k, v in v.items() if not k.startswith('_')])
return ''.join([v for k, v in v.items() if not k.startswith('_') and v])
else:
return v

View File

@@ -794,6 +794,26 @@ def test_reupload_same_nonce(token_client, organizer, clist, event, order):
resp = _redeem(token_client, organizer, clist, p.pk, {'nonce': 'foobar'})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert p.all_checkins.count() == 1
@pytest.mark.django_db
def test_reupload_same_nonce_not_ignored_after_failed(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
p.all_checkins.create(
type=Checkin.TYPE_ENTRY,
nonce='foobar',
successful=False,
list=clist,
)
resp = _redeem(token_client, organizer, clist, p.pk, {'nonce': 'foobar'})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert p.all_checkins.count() == 2
@pytest.mark.django_db
@@ -1105,6 +1125,7 @@ def test_store_failed(token_client, organizer, clist, event, order):
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '4321',
'error_reason': 'invalid'
}, format='json')
assert resp.status_code == 201
@@ -1115,6 +1136,7 @@ def test_store_failed(token_client, organizer, clist, event, order):
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '1234',
'position': p.pk,
'error_reason': 'unpaid'
}, format='json')
@@ -1122,6 +1144,28 @@ def test_store_failed(token_client, organizer, clist, event, order):
with scopes_disabled():
assert p.all_checkins.filter(successful=False).count() == 1
# Ignore sending the same nonces again
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '4321',
'error_reason': 'invalid'
}, format='json')
assert resp.status_code == 201
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '1234',
'position': p.pk,
'error_reason': 'unpaid'
}, format='json')
assert resp.status_code == 201
with scopes_disabled():
assert Checkin.all.filter(successful=False).count() == 2
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {

View File

@@ -211,7 +211,7 @@ def _redeem(token_client, org, clist, p, body=None, query=''):
def test_query_load(token_client, organizer, clist, event, order, django_assert_max_num_queries):
with scopes_disabled():
p = order.positions.first()
with django_assert_max_num_queries(30):
with django_assert_max_num_queries(29):
resp = _redeem(token_client, organizer, clist, p.secret)
assert resp.status_code == 201
assert resp.data['status'] == 'ok'

View File

@@ -104,6 +104,34 @@ def test_customer_create(token_client, organizer):
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_customer_create_email_unique(token_client, organizer):
resp = token_client.post(
'/api/v1/organizers/{}/customers/'.format(organizer.slug),
format='json',
data={
'identifier': 'IGNORED',
'email': 'bar@example.com',
'password': 'foobar',
'is_active': True,
'is_verified': True,
}
)
assert resp.status_code == 201
resp = token_client.post(
'/api/v1/organizers/{}/customers/'.format(organizer.slug),
format='json',
data={
'identifier': 'IGNORED',
'email': 'bar@example.com',
'password': 'foobar',
'is_active': True,
'is_verified': True,
}
)
assert resp.status_code == 400
@pytest.mark.django_db
def test_customer_create_send_email(token_client, organizer):
resp = token_client.post(

View File

@@ -52,7 +52,11 @@ TEST_DISCOUNT_RES = {
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
"benefit_only_apply_to_cheapest_n_matches": 1,
"benefit_same_products": True,
"benefit_limit_products": [],
"benefit_apply_to_addons": True,
"benefit_ignore_voucher_discounted": False,
}

View File

@@ -137,6 +137,37 @@ def order(event, item, taxrule, question):
return o
@pytest.fixture
def order2(event2, item2):
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='BAR', event=event2, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="asd436cvbfd1",
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc),
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc),
total=23, locale='en'
)
o.payments.create(
provider='banktransfer',
state='pending',
amount=Decimal('23.00'),
)
OrderPosition.objects.create(
order=o,
item=item2,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="asdlfksdgdfgxcbfgdhfg",
pseudonymization_id="AC892345",
positionid=1,
)
return o
@pytest.fixture
def invoice(order):
testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc)
@@ -146,8 +177,18 @@ def invoice(order):
return generate_invoice(order)
@pytest.fixture
def invoice2(order2):
testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
return generate_invoice(order2)
TEST_INVOICE_RES = {
"order": "FOO",
"event": "dummy",
"number": "DUMMY-00001",
"is_cancellation": False,
"invoice_from_name": "",
@@ -268,6 +309,34 @@ def test_invoice_list(token_client, organizer, event, order, item, invoice):
assert [] == resp.data['results']
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2):
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
@pytest.mark.django_db
def test_invoice_detail(token_client, organizer, event, item, invoice):
res = dict(TEST_INVOICE_RES)

View File

@@ -420,6 +420,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
pos = order.positions.first()
assert json.loads(json.dumps(resp.data)) == {
'order': 'FOO',
'event': 'dummy',
'number': 'DUMMY-00001',
'is_cancellation': False,
"invoice_from_name": "",

View File

@@ -309,6 +309,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
del d['positions'][0]['secret']
assert d == {
'code': 'PREVIEW',
'event': 'dummy',
'status': 'n',
'testmode': False,
'email': 'dummy@dummy.test',

View File

@@ -139,6 +139,37 @@ def order(event, item, taxrule, question):
return o
@pytest.fixture
def order2(event2, item2):
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='BAR', event=event2, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="asd436cvbfd1",
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc),
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc),
total=23, locale='en'
)
o.payments.create(
provider='banktransfer',
state='pending',
amount=Decimal('23.00'),
)
OrderPosition.objects.create(
order=o,
item=item2,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="asdlfksdgdfgxcbfgdhfg",
pseudonymization_id="AC892345",
positionid=1,
)
return o
@pytest.fixture
def clist_autocheckin(event):
c = event.checkin_lists.create(name="Default", all_products=True, auto_checkin_sales_channels=['web'])
@@ -228,6 +259,7 @@ TEST_REFUNDS_RES = [
]
TEST_ORDER_RES = {
"code": "FOO",
"event": "dummy",
"status": "n",
"testmode": False,
"secret": "k24fiuwvu8kxz3y1",
@@ -460,6 +492,34 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques
assert len(resp.data['fees']) == 2
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, order, order2):
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
@pytest.mark.django_db
def test_include_exclude_fields(token_client, organizer, event, order, item, taxrule, question):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?exclude=positions.secret'.format(

View File

@@ -97,15 +97,7 @@ def test_payment_fee_reverse_percent_and_abs_default(event):
def test_availability_date_available(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_date', datetime.date.today() + datetime.timedelta(days=1))
result = prov._is_available_by_time()
assert result
@pytest.mark.django_db
def test_availability_start_available(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_start', datetime.date.today() - datetime.timedelta(days=1))
result = prov._is_available_by_time()
result = prov._is_still_available()
assert result
@@ -113,15 +105,7 @@ def test_availability_start_available(event):
def test_availability_date_not_available(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_date', datetime.date.today() - datetime.timedelta(days=1))
result = prov._is_available_by_time()
assert not result
@pytest.mark.django_db
def test_availability_start_not_available(event):
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_start', datetime.date.today() + datetime.timedelta(days=1))
result = prov._is_available_by_time()
result = prov._is_still_available()
assert not result
@@ -137,26 +121,9 @@ def test_availability_date_relative(event):
))
utc = datetime.timezone.utc
assert prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc))
assert not prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc))
@pytest.mark.django_db
def test_availability_start_relative(event):
event.settings.set('timezone', 'US/Pacific')
tz = ZoneInfo('US/Pacific')
event.date_from = datetime.datetime(2016, 12, 3, 12, 0, 0, tzinfo=tz)
event.save()
prov = DummyPaymentProvider(event)
prov.settings.set('_availability_start', RelativeDateWrapper(
RelativeDate(days_before=2, time=datetime.time(12, 0), base_date_name='date_from', minutes_before=None)
))
utc = datetime.timezone.utc
assert not prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc))
assert prov._is_still_available(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_still_available(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc))
assert not prov._is_still_available(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc))
@pytest.mark.django_db
@@ -167,9 +134,9 @@ def test_availability_date_timezones(event):
tz = ZoneInfo('US/Pacific')
utc = ZoneInfo('UTC')
assert prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc))
assert not prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc))
assert prov._is_still_available(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc))
assert prov._is_still_available(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc))
assert not prov._is_still_available(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc))
@pytest.mark.django_db
@@ -195,12 +162,12 @@ def test_availability_date_cart_relative_subevents(event):
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days_before=3, time=None, base_date_name='date_from', minutes_before=None)
))
assert prov._is_available_by_time(cart_id="123")
assert prov._is_still_available(cart_id="123")
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days_before=4, time=None, base_date_name='date_from', minutes_before=None)
))
assert not prov._is_available_by_time(cart_id="123")
assert not prov._is_still_available(cart_id="123")
@pytest.mark.django_db
@@ -234,9 +201,9 @@ def test_availability_date_order_relative_subevents(event):
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days_before=3, time=None, base_date_name='date_from', minutes_before=None)
))
assert prov._is_available_by_time(order=order)
assert prov._is_still_available(order=order)
prov.settings.set('_availability_date', RelativeDateWrapper(
RelativeDate(days_before=4, time=None, base_date_name='date_from', minutes_before=None)
))
assert not prov._is_available_by_time(order=order)
assert not prov._is_still_available(order=order)

View File

@@ -1012,3 +1012,304 @@ def test_available_until(event, item):
)
assert sorted([p for p, d in apply_discounts(event, 'web', positions)]) == [Decimal('50.00'), Decimal('50.00')]
@pytest.mark.django_db
@scopes_disabled()
def test_discount_other_products_min_count(event, item, item2):
# "For every 5 item2, one item1 gets in for free"
d1 = Discount(
event=event,
condition_min_count=5,
condition_all_products=False,
benefit_discount_matching_percent=100,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_same_products=False,
)
d1.save()
d1.condition_limit_products.add(item2)
d1.benefit_limit_products.add(item)
positions = (
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
)
expected = (
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('90.00'),
Decimal('0.00'),
Decimal('0.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
@pytest.mark.django_db
@scopes_disabled()
def test_discount_other_products_min_count_no_addon(event, item, item2):
# "For every 2 item2, one item1 gets in for free, but no addons"
d1 = Discount(
event=event,
condition_min_count=2,
condition_all_products=False,
benefit_discount_matching_percent=100,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_same_products=False,
benefit_apply_to_addons=False,
)
d1.save()
d1.condition_limit_products.add(item2)
d1.benefit_limit_products.add(item)
positions = (
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), True, False, Decimal('0.00')),
)
expected = (
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('90.00'),
Decimal('0.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
@pytest.mark.django_db
@scopes_disabled()
def test_discount_other_products_min_count_no_voucher(event, item, item2):
# "For every 2 item2, one item1 gets in for free, but no discount on already discounted"
d1 = Discount(
event=event,
condition_min_count=2,
condition_all_products=False,
benefit_discount_matching_percent=100,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_same_products=False,
benefit_ignore_voucher_discounted=True,
)
d1.save()
d1.condition_limit_products.add(item2)
d1.benefit_limit_products.add(item)
positions = (
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('40.00'), False, False, Decimal('50.00')),
(item.pk, None, Decimal('40.00'), False, False, Decimal('50.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
)
expected = (
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('40.00'),
Decimal('40.00'),
Decimal('0.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
@pytest.mark.django_db
@scopes_disabled()
def test_discount_subgroup_cheapest_n_min_count(event, item, item2):
# "For every 4 products, you get one free, but only of type item"
d1 = Discount(
event=event,
condition_min_count=4,
condition_all_products=False,
benefit_discount_matching_percent=100,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_same_products=False,
)
d1.save()
d1.condition_limit_products.add(item)
d1.condition_limit_products.add(item2)
d1.benefit_limit_products.add(item)
positions = (
# 11 items of item2, which contribute to the total count of 15, but do not get reduced
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
# 4 items of item, of which 3 of the cheapest will be reduced
(item.pk, None, Decimal('110.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('110.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')),
)
expected = (
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('100.00'),
Decimal('110.00'),
Decimal('0.00'),
Decimal('0.00'),
Decimal('0.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
@pytest.mark.django_db
@scopes_disabled()
def test_discount_other_products_min_value(event, item, item2):
# "If you buy item1 for at least €99, you get all item2 for 20% off"
d1 = Discount(
event=event,
condition_min_value=99,
condition_all_products=False,
benefit_discount_matching_percent=20,
benefit_same_products=False,
)
d1.save()
d1.condition_limit_products.add(item)
d1.benefit_limit_products.add(item2)
positions = (
(item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
)
expected = (
Decimal('50.00'),
Decimal('23.00'),
Decimal('23.00'),
Decimal('23.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
positions = (
(item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
)
expected = (
Decimal('50.00'),
Decimal('50.00'),
Decimal('18.40'),
Decimal('18.40'),
Decimal('18.40'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)
@pytest.mark.django_db
@scopes_disabled()
def test_multiple_discounts_with_benefit_condition_overlap(event, item, item2):
# "For every 5 item2, you get one item1 for 20 % off." + "For every two item1, you get one 10% off."
d1 = Discount(
event=event,
condition_min_count=5,
condition_all_products=False,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_discount_matching_percent=20,
benefit_same_products=False,
position=1,
)
d1.save()
d1.condition_limit_products.add(item2)
d1.benefit_limit_products.add(item)
d2 = Discount(
event=event,
condition_min_count=2,
condition_all_products=False,
benefit_only_apply_to_cheapest_n_matches=1,
benefit_discount_matching_percent=10,
benefit_same_products=True,
position=2,
)
d2.save()
d2.condition_limit_products.add(item)
positions = (
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
(item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')),
)
expected = (
# item2 remains untouched
Decimal('50.00'),
Decimal('50.00'),
Decimal('50.00'),
Decimal('50.00'),
Decimal('50.00'),
Decimal('50.00'),
# one item is reduced 20% because we have >5 item2
Decimal('18.40'),
# one item is reduced 10% because it's part of a group of two
Decimal('20.70'),
# two remain full price
Decimal('23.00'),
Decimal('23.00'),
)
new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
assert sorted(new_prices) == sorted(expected)

View File

@@ -22,79 +22,122 @@
import pytest
from pretix.base.templatetags.rich_text import (
markdown_compile_email, rich_text, rich_text_snippet,
ALLOWED_ATTRIBUTES, ALLOWED_TAGS, markdown_compile_email, rich_text,
rich_text_snippet,
)
@pytest.mark.parametrize("link", [
# Test link detection
("google.com",
'<a href="http://google.com" rel="noopener" target="_blank">google.com</a>'),
# Test link escaping
("google\\.com", 'google.com'),
# Test abslink_callback
("[Call](tel:+12345)",
'<a href="tel:+12345" rel="nofollow">Call</a>'),
("[Foo](/foo)",
'<a href="http://example.com/foo" rel="noopener" target="_blank">Foo</a>'),
("mail@example.org",
'<a href="mailto:mail@example.org">mail@example.org</a>'),
# Test truelink_callback
('evilsite.com',
'<a href="http://evilsite.com" rel="noopener" target="_blank">evilsite.com</a>'),
('cool-example.eu',
'<a href="http://cool-example.eu" rel="noopener" target="_blank">cool-example.eu</a>'),
('<a href="https://evilsite.com">Evil GmbH & Co. KG</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">Evil GmbH &amp; Co. KG</a>'),
('<a href="https://evilsite.com">Evil Site</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">Evil Site</a>'),
('<a href="https://evilsite.com">evilsite.com</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">evilsite.com</a>'),
('<a href="https://evilsite.com">goodsite.com</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">https://evilsite.com</a>'),
('<a href="https://goodsite.com.evilsite.com">goodsite.com</a>',
'<a href="https://goodsite.com.evilsite.com" rel="noopener" target="_blank">https://goodsite.com.evilsite.com</a>'),
('<a href="https://evilsite.com/deep/path">evilsite.com</a>',
'<a href="https://evilsite.com/deep/path" rel="noopener" target="_blank">evilsite.com</a>'),
('<a>broken</a>', '<a>broken</a>'),
])
@pytest.mark.parametrize(
"link",
[
# Test link detection
(
"google.com",
'<a href="http://google.com" rel="noopener" target="_blank">google.com</a>',
),
# Test link escaping
("google\\.com", "google.com"),
# Test abslink_callback
("[Call](tel:+12345)", '<a href="tel:+12345" rel="nofollow">Call</a>'),
(
"[Foo](/foo)",
'<a href="http://example.com/foo" rel="noopener" target="_blank">Foo</a>',
),
("mail@example.org", '<a href="mailto:mail@example.org">mail@example.org</a>'),
# Test truelink_callback
(
"evilsite.com",
'<a href="http://evilsite.com" rel="noopener" target="_blank">evilsite.com</a>',
),
(
"cool-example.eu",
'<a href="http://cool-example.eu" rel="noopener" target="_blank">cool-example.eu</a>',
),
(
'<a href="https://evilsite.com">Evil GmbH & Co. KG</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">Evil GmbH &amp; Co. KG</a>',
),
(
'<a href="https://evilsite.com">Evil Site</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">Evil Site</a>',
),
(
'<a href="https://evilsite.com">evilsite.com</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">evilsite.com</a>',
),
(
'<a href="https://evilsite.com">goodsite.com</a>',
'<a href="https://evilsite.com" rel="noopener" target="_blank">https://evilsite.com</a>',
),
(
'<a href="https://goodsite.com.evilsite.com">goodsite.com</a>',
'<a href="https://goodsite.com.evilsite.com" rel="noopener" target="_blank">https://goodsite.com.evilsite.com</a>',
),
(
'<a href="https://evilsite.com/deep/path">evilsite.com</a>',
'<a href="https://evilsite.com/deep/path" rel="noopener" target="_blank">evilsite.com</a>',
),
("<a>broken</a>", "<a>broken</a>"),
],
)
def test_linkify_abs(link):
input, output = link
assert rich_text_snippet(input, safelinks=False) == output
assert rich_text(input, safelinks=False) == f'<p>{output}</p>'
assert markdown_compile_email(input) == f'<p>{output}</p>'
assert rich_text(input, safelinks=False) == f"<p>{output}</p>"
assert markdown_compile_email(input) == f"<p>{output}</p>"
@pytest.mark.parametrize("content,result", [
('a\nb', '<p>a<br>\nb</p>'),
('a \nb', '<p>a<br>\nb</p>'),
('a\n\nb', '<p>a</p>\n<p>b</p>'),
])
@pytest.mark.parametrize(
"content,result",
[
("a\nb", "<p>a<br>\nb</p>"),
("a \nb", "<p>a<br>\nb</p>"),
("a\n\nb", "<p>a</p>\n<p>b</p>"),
],
)
def test_newline_handling(content, result):
assert rich_text(content, safelinks=False) == result
@pytest.mark.parametrize("content,result", [
('a\nb', '<p>a\nb</p>'),
('a \nb', '<p>a<br>\nb</p>'),
('a\n\nb', '<p>a</p>\n<p>b</p>'),
])
@pytest.mark.parametrize(
"content,result",
[
("a\nb", "<p>a\nb</p>"),
("a \nb", "<p>a<br>\nb</p>"),
("a\n\nb", "<p>a</p>\n<p>b</p>"),
],
)
def test_newline_handling_email(content, result):
assert markdown_compile_email(content) == result
@pytest.mark.parametrize("content,result,result_snippet", [
# attributes
('<a onclick="javascript:foo()">foo</a>', '<p><a>foo</a></p>', '<a>foo</a>'),
('<strong color="red">foo</strong>',
'<p><strong>foo</strong></p>',
'<strong>foo</strong>'),
# protocols
('<a href="javascript:foo()">foo</a>', '<p><a>foo</a></p>', '<a>foo</a>'),
# tags
('<script>foo</script>', '&lt;script&gt;foo&lt;/script&gt;', 'foo'),
])
@pytest.mark.parametrize(
"content,result,result_snippet",
[
# attributes
('<a onclick="javascript:foo()">foo</a>', "<p><a>foo</a></p>", "<a>foo</a>"),
(
'<strong color="red">foo</strong>',
"<p><strong>foo</strong></p>",
"<strong>foo</strong>",
),
# protocols
('<a href="javascript:foo()">foo</a>', "<p><a>foo</a></p>", "<a>foo</a>"),
# tags
("<script>foo</script>", "&lt;script&gt;foo&lt;/script&gt;", "foo"),
],
)
def test_cleanup(content, result, result_snippet):
assert rich_text(content) == result
assert rich_text_snippet(content) == result_snippet
assert markdown_compile_email(content) == result
def test_markdown_email_custom_allowlist():
source = "![my image](https://example.org/my-image.jpg)"
html = markdown_compile_email(
source,
allowed_tags=ALLOWED_TAGS + ["img"],
allowed_attributes=dict(ALLOWED_ATTRIBUTES, img=["src", "alt", "title"]),
)
assert html == '<p><img alt="my image" src="https://example.org/my-image.jpg"></p>'