forked from CGM_Public/pretix_original
Compare commits
13 Commits
payment-av
...
customer-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd0612277 | ||
|
|
53e84dfb08 | ||
|
|
2e8447486c | ||
|
|
5b184bb1a0 | ||
|
|
8c6f0a5dc1 | ||
|
|
6a53091b91 | ||
|
|
be4bc9a6f3 | ||
|
|
efb1141d59 | ||
|
|
322a730eb2 | ||
|
|
8d2224e725 | ||
|
|
5b819b76f0 | ||
|
|
5d90a42acf | ||
|
|
5398671fde |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal file
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors')
|
||||
help_text=_("e.g. {sample}").format(
|
||||
sample='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
)
|
||||
)),
|
||||
])
|
||||
responses = register_global_settings.send(self)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
), {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 & 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>', '<script>foo</script>', '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>", "<script>foo</script>", "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 = ""
|
||||
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>'
|
||||
|
||||
Reference in New Issue
Block a user