Compare commits

..

2 Commits

Author SHA1 Message Date
Mira Weller
8a1e1a7de1 refactoring 2023-09-12 18:40:40 +02:00
Raphael Michel
c246a46b15 Payment providers: Allow to set an availability start date per method (Z#23126769) 2023-09-04 16:59:59 +02:00
38 changed files with 393 additions and 1169 deletions

View File

@@ -31,9 +31,9 @@ subevent_mode strings Determines h
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
condition_all_products boolean If ``true``, the discount condition applies to all items.
condition_all_products boolean If ``true``, the discount 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 condition applies to.
of internal item IDs that the discount 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,17 +48,6 @@ 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.
======================================== ========================== =======================================================
@@ -105,10 +94,6 @@ 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
}
@@ -161,10 +146,6 @@ 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
}
@@ -203,10 +184,6 @@ 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
}
@@ -234,10 +211,6 @@ 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
}
@@ -294,10 +267,6 @@ Endpoints
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}

View File

@@ -12,7 +12,6 @@ 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.
@@ -122,13 +121,9 @@ internal_reference string Customer's refe
The attribute ``lines.subevent`` has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.
List of all invoices
--------------------
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/
@@ -157,7 +152,6 @@ List of all invoices
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -227,50 +221,6 @@ List of all invoices
: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.
@@ -293,7 +243,6 @@ Fetching individual invoices
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -388,12 +337,6 @@ Fetching individual invoices
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Modifying invoices
------------------
Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one.

View File

@@ -20,7 +20,6 @@ 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
@@ -131,10 +130,6 @@ 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:
@@ -294,7 +289,6 @@ List of all orders
"results": [
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
@@ -447,48 +441,6 @@ 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
--------------------------
@@ -514,7 +466,6 @@ Fetching individual orders
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",

View File

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

View File

@@ -32,13 +32,11 @@ 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',
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data):
data = super().validate(data)

View File

@@ -284,12 +284,11 @@ 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', 'nonce', 'datetime', 'type', 'position')
'raw_subevent', 'datetime', 'type', 'position')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -615,7 +614,7 @@ class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None
return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
'order': instance.order.code,
'secret': instance.order.secret,
'payment': instance.pk,
@@ -660,7 +659,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order):
return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
'order': instance.code,
'secret': instance.secret,
})
@@ -695,7 +694,6 @@ 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)
@@ -711,7 +709,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
list_serializer_class = OrderListSerializer
fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'code', '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'
@@ -1595,7 +1593,6 @@ 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)
@@ -1604,7 +1601,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta:
model = Invoice
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',

View File

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

View File

@@ -61,8 +61,6 @@ 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()
@@ -79,7 +77,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.EventOrderViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')

View File

@@ -164,21 +164,8 @@ 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=clist,
list=self.get_object(),
successful=False,
forced=True,
force_sent=True,

View File

@@ -44,7 +44,6 @@ 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
@@ -186,7 +185,7 @@ with scopes_disabled():
)
class OrderViewSetMixin:
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
@@ -194,12 +193,19 @@ class OrderViewSetMixin:
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_base_queryset(self):
raise NotImplementedError()
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_queryset(self):
qs = self.get_base_queryset()
qs = self.request.event.orders
if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
@@ -221,12 +227,11 @@ class OrderViewSetMixin:
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
if request.query_params.get('pdf_data', 'false') == 'true':
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',
)
@@ -261,12 +266,13 @@ class OrderViewSetMixin:
)
)
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
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.')
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
@@ -283,45 +289,6 @@ class OrderViewSetMixin:
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)
@@ -1815,24 +1782,11 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
write_permission = 'can_change_orders'
def get_queryset(self):
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(
return self.request.event.invoices.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()
@@ -1851,7 +1805,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return resp
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwargs):
def regenerate(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
@@ -1861,7 +1815,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(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1):
elif now().astimezone(self.request.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)
@@ -1876,7 +1830,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=204)
@action(detail=True, methods=['POST'])
def reissue(self, request, **kwargs):
def reissue(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')

View File

@@ -202,21 +202,6 @@ 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 (
@@ -365,18 +350,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.orders.waitinglist.voucher_assigned',
_('Waiting list entry received voucher'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.created',
_('Customer account created'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.changed',
_('Customer account changed'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.anonymized',
_('Customer account anonymized'),
),
)

View File

@@ -134,11 +134,8 @@ 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 = self.compile_markdown(plain_body)
body_md = markdown_compile_email(plain_body)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
@@ -156,7 +153,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if plain_signature:
signature_md = plain_signature.replace('\n', '<br>\n')
signature_md = self.compile_markdown(signature_md)
signature_md = markdown_compile_email(signature_md)
htmlctx['signature'] = signature_md
if order:

View File

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

View File

@@ -99,7 +99,7 @@ class Discount(LoggedModel):
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Count add-on products"),
verbose_name=_("Apply to 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 be considered."),
"hidden product or gain access to sold-out quota will still receive the discount."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
@@ -120,19 +120,6 @@ 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,
@@ -152,18 +139,6 @@ 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
@@ -212,14 +187,6 @@ 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()
@@ -230,7 +197,6 @@ 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:
@@ -241,14 +207,14 @@ class Discount(LoggedModel):
return False
return True
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:
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:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group:
for idx in idx_group:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
@@ -256,8 +222,8 @@ class Discount(LoggedModel):
)
result[idx] = new_price
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
if len(condition_idx_group) < self.condition_min_count:
def _apply_min_count(self, positions, idx_group, result):
if len(idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
@@ -267,17 +233,15 @@ class Discount(LoggedModel):
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
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
idx_group = sorted(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
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]
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]
else:
consume_idx = condition_idx_group
benefit_idx = benefit_idx_group
consume_idx = idx_group
benefit_idx = idx_group
for idx in benefit_idx:
previous_price = positions[idx][2]
@@ -312,7 +276,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
condition_candidates = [
initial_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
@@ -322,25 +286,11 @@ 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, condition_candidates, benefit_candidates, result)
self._apply_min_count(positions, initial_candidates, result)
else:
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
self._apply_min_value(positions, initial_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
@@ -349,18 +299,17 @@ class Discount(LoggedModel):
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(condition_candidates, key=key), key=key)
candidate_groups = [(k, list(g)) for k, g in _groups]
_groups = groupby(sorted(initial_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups]
for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
for g in candidate_groups:
if self.condition_min_count:
self._apply_min_count(positions, g, benefit_g, result)
self._apply_min_count(positions, g, result)
else:
self._apply_min_value(positions, g, benefit_g, result)
self._apply_min_value(positions, g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products:
if self.condition_min_value:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
@@ -387,7 +336,7 @@ class Discount(LoggedModel):
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
l = [ll for ll in l if ll in initial_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}:
@@ -424,5 +373,5 @@ class Discount(LoggedModel):
break
for g in candidate_groups:
self._apply_min_count(positions, g, g, result)
self._apply_min_count(positions, g, result)
return result

View File

@@ -907,18 +907,14 @@ 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'):
c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
items = list(d.condition_limit_products.all())
d.pk = None
d.event = self
d.save(force_insert=True)
d.log_action('pretix.object.cloned')
for i in c_items:
for i in items:
if i.pk in item_map:
d.condition_limit_products.add(item_map[i.pk])
for i in b_items:
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):

View File

@@ -336,6 +336,12 @@ 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'),
@@ -539,40 +545,57 @@ class BasePaymentProvider:
return form
def _is_still_available(self, now_dt=None, cart_id=None, order=None):
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):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
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()
try:
availability_start = self._convert_availability_date_to_absolute(
self.settings.get('_availability_start', as_type=RelativeDateWrapper), cart_id, order)
if availability_date:
return availability_date >= now_dt.astimezone(tz).date()
if availability_start:
if availability_start > now_dt.astimezone(tz).date():
return False
return True
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
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
"""
@@ -581,9 +604,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 _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 ``_availability_from``, ``_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.
@@ -592,7 +615,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_still_available(cart_id=get_or_create_cart_id(request))
timing = self._is_available_by_time(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:
@@ -776,8 +799,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 _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 ``_availabilty_from``, ``_total_max``, ``_total_min``, and ``_restricted_countries`` settings.
:param order: The order object
"""
@@ -804,7 +827,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_still_available(order=order)
return self._is_available_by_time(order=order)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
"""

View File

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

View File

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

View File

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

View File

@@ -50,16 +50,11 @@ 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,
@@ -69,14 +64,11 @@ 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):
@@ -93,7 +85,6 @@ class DiscountForm(I18nModelForm):
widget=forms.CheckboxSelectMultiple,
)
self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['benefit_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False
self.fields['condition_min_count'].widget.is_required = False
self.fields['condition_min_value'].required = False

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-07-27 11:49+0000\n"
"PO-Revision-Date: 2023-08-25 04:00+0000\n"
"PO-Revision-Date: 2023-08-24 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,30 +7045,38 @@ msgstr ""
"door u gekozen hoeveelheid. Zie hieronder voor de details."
#: pretix/base/services/cart.py:114
#, python-format
#, fuzzy, python-format
#| msgid "You cannot select more than %s items per order."
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 item per bestelling kiezen."
msgstr[0] "U kunt niet meer dan %s items 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "You cannot select more than %(max)s items of the product %(product)s."
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 item van product %(product)s kiezen."
msgstr[0] "U kunt niet meer dan %(max)s items 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "You need to select at least %(min)s items of the product %(product)s."
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 item van product %(product)s kiezen."
msgstr[0] "U moet ten minste %(min)s items 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "We removed %(product)s from your cart as you can not buy less than "
#| "%(min)s items of it."
msgid ""
"We removed %(product)s from your cart as you can not buy less than %(min)s "
"item of it."
@@ -7077,10 +7085,10 @@ msgid_plural ""
"items of it."
msgstr[0] ""
"We hebben %(product)s uit uw winkelwagen verwijderd, omdat u niet minder dan "
"%(min)s item ervan kunt kopen."
"%(min)s ervan kunt kopen."
msgstr[1] ""
"We hebben %(product)s uit uw winkelwagen verwijderd, omdat u niet minder dan "
"%(min)s items ervan kunt kopen."
"%(min)s ervan kunt kopen."
#: pretix/base/services/cart.py:132 pretix/base/services/orders.py:146
#: pretix/presale/templates/pretixpresale/event/index.html:157
@@ -7242,7 +7250,10 @@ 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "You can select at most %(max)s add-ons from the category %(cat)s for the "
#| "product %(base)s."
msgid ""
"You can select at most %(max)s add-on from the category %(cat)s for the "
"product %(base)s."
@@ -7250,14 +7261,17 @@ 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-on van de categorie %(cat)s selecteren voor het "
"U kunt maximaal %(max)s add-ons 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "You need to select at least %(min)s add-ons from the category %(cat)s for "
#| "the product %(base)s."
msgid ""
"You need to select at least %(min)s add-on from the category %(cat)s for the "
"product %(base)s."
@@ -7265,7 +7279,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-on van de categorie %(cat)s selecteren voor het "
"U moet minimaal %(min)s add-ons 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 "
@@ -7430,14 +7444,16 @@ 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
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "Only allowed after {datetime}"
msgid "This ticket is only valid after {datetime}."
msgstr "Dit ticket is geldig vanaf {datetime}."
msgstr "Alleen toegestaan vanaf {datetime}"
#: pretix/base/services/checkin.py:795 pretix/base/services/checkin.py:799
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "This ticket has already been redeemed."
msgid "This ticket was only valid before {datetime}."
msgstr "Dit ticket was geldig vòòr {datetime}."
msgstr "Dit ticket is al gebruikt."
#: pretix/base/services/checkin.py:830
msgid "This order position has an invalid product for this check-in list."
@@ -7498,8 +7514,10 @@ 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."
@@ -7530,10 +7548,11 @@ msgstr ""
"{country}"
#: pretix/base/services/invoices.py:220 pretix/base/services/invoices.py:257
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "Event location"
msgctxt "invoice"
msgid "Event location: {location}"
msgstr "Evenementlocatie: {location}"
msgstr "Evenementlocatie"
#: pretix/base/services/invoices.py:236
#, python-brace-format

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-07-27 11:49+0000\n"
"PO-Revision-Date: 2023-08-30 07:00+0000\n"
"Last-Translator: Ash So <ashs@vankaifong.com>\n"
"PO-Revision-Date: 2023-06-28 06:00+0000\n"
"Last-Translator: Yucheng Lin <yuchenglinedu@gmail.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.18.2\n"
"X-Generator: Weblate 4.17\n"
#: pretix/_base_settings.py:78
msgid "English"
@@ -5876,18 +5876,24 @@ 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 "Apple Pay"
msgstr "應用"
#: pretix/base/payment.py:87
#, fuzzy
#| msgid "Android (Google Play)"
msgctxt "payment"
msgid "Google Pay"
msgstr "安卓Google Pay"
msgstr "安卓Google Play"
#: pretix/base/payment.py:256
#: pretix/presale/templates/pretixpresale/event/order.html:115
@@ -6418,12 +6424,16 @@ 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"
"2x附加2"
"附加2"
#: pretix/base/pdf.py:370 pretix/control/forms/filter.py:1275
#: pretix/control/forms/filter.py:1277
@@ -9400,12 +9410,25 @@ msgstr ""
"你的{event} 團隊"
#: pretix/base/settings.py:2349
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "Payment received for your order: {code}"
msgid "Payment failed for your order: {code}"
msgstr "訂單付款失敗{code}"
msgstr "收到的訂單付款:{code}"
#: pretix/base/settings.py:2353
#, python-brace-format
#, 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"
msgid ""
"Hello,\n"
"\n"
@@ -9423,12 +9446,11 @@ msgid ""
msgstr ""
"你好\n"
"\n"
"你在 {event} 訂單付款未能成功。\n"
"我們尚未收到你的 {event} 訂單的全額付款。\n"
"請記住,我們僅在收到時保證你的訂單\n"
"你在 {expire_date} 之前的付款。\n"
"\n"
"您的訂單仍然有效,您可以嘗試使用相同或不同的付款方式再次進行支付,唯請在 "
"{expire_date} 前完成付款程序。\n"
"\n"
"您可以重新嘗試付款,並在以下網址檢視您的訂單狀態:\n"
"你可以在以下位置查看付款資訊與訂單狀態:\n"
"{url}\n"
"\n"
"敬此\n"
@@ -13546,7 +13568,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
@@ -13824,8 +13846,10 @@ 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."
@@ -14061,8 +14085,11 @@ 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
@@ -16761,8 +16788,10 @@ 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"
@@ -16816,6 +16845,8 @@ msgid "Deadlines"
msgstr "期限"
#: pretix/control/templates/pretixcontrol/event/payment.html:68
#, fuzzy
#| msgid "days"
msgctxt "unit"
msgid "days"
msgstr "日"
@@ -21950,11 +21981,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."
@@ -22498,14 +22529,20 @@ 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
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "\n"
#| " Waiting, product %(num)sx available\n"
#| " "
msgid ""
"\n"
" Waiting, product %(num)sx "
@@ -22513,8 +22550,8 @@ msgid ""
" "
msgstr ""
"\n"
" 等待中,產品 %(num)s可用\n"
" "
" 等待中,產品 %(num)s可用\n"
" "
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:231
msgid "Waiting, product unavailable"
@@ -23641,7 +23678,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
@@ -25237,7 +25274,7 @@ msgstr "值機清單 PDF"
#: pretix/plugins/checkinlists/exporters.py:648
msgctxt "export_category"
msgid "Check-in"
msgstr "簽到"
msgstr "Check-in"
#: pretix/plugins/checkinlists/exporters.py:286
msgid ""
@@ -26070,6 +26107,9 @@ 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 "傳送到"
@@ -26084,6 +26124,9 @@ 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 "僅限未簽到的收件者"
@@ -26133,11 +26176,17 @@ 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 "僅限在清單中簽到的收件者"
@@ -26208,8 +26257,11 @@ 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"
@@ -26788,8 +26840,10 @@ 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
@@ -26921,20 +26975,26 @@ msgid "Credit card"
msgstr "信用卡"
#: pretix/plugins/stripe/payment.py:1157
#, fuzzy
#| msgid "EPS via Stripe"
msgid "SEPA Debit via Stripe"
msgstr "透過Stripe進行SEPA直接扣款"
msgstr "EPS透過Stripe"
#: pretix/plugins/stripe/payment.py:1158
msgid "SEPA Debit"
msgstr "SEPA扣款"
msgstr ""
#: 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
@@ -26943,12 +27003,16 @@ 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"
@@ -27119,8 +27183,10 @@ 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
@@ -27129,8 +27195,10 @@ 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
@@ -27178,14 +27246,20 @@ 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
@@ -27465,7 +27539,7 @@ msgstr "網上check-in"
#: pretix/plugins/webcheckin/templates/pretixplugins/webcheckin/index.html:10
msgid "Check-in"
msgstr "簽到"
msgstr "Check-in"
#: pretix/presale/checkoutflow.py:107
msgctxt "checkoutflow"

View File

@@ -607,12 +607,6 @@ 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
@@ -621,15 +615,9 @@ class PaypalMethod(BasePaymentProvider):
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id)
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
if Decimal(pp_captured_order.purchase_units[0].amount.value) != payment.amount or \
if str(pp_captured_order.purchase_units[0].amount.value) != str(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.'))
@@ -672,12 +660,6 @@ class PaypalMethod(BasePaymentProvider):
self.client.execute(patchreq)
except IOError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
payment.fail(info={
"error": {
"name": "IOError",
"message": str(e),
}
})
logger.exception('PayPal OrdersPatchRequest: {}'.format(str(e)))
return

View File

@@ -554,9 +554,6 @@ 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 = {}
@@ -1581,9 +1578,6 @@ class StripeSofort(StripeMethod):
return True
return False
def payment_can_retry(self, payment):
return payment.state != OrderPayment.PAYMENT_STATE_PENDING and self._is_still_available(order=payment.order)
def payment_presale_render(self, payment: OrderPayment) -> str:
pi = payment.info_data or {}
try:

View File

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

View File

@@ -794,26 +794,6 @@ 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
@@ -1125,7 +1105,6 @@ 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
@@ -1136,7 +1115,6 @@ 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')
@@ -1144,28 +1122,6 @@ def test_store_failed(token_client, organizer, clist, event, order):
with scopes_disabled():
assert p.all_checkins.filter(successful=False).count() == 1
# Ignore sending the same nonces again
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '4321',
'error_reason': 'invalid'
}, format='json')
assert resp.status_code == 201
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {
'raw_barcode': '123456',
'nonce': '1234',
'position': p.pk,
'error_reason': 'unpaid'
}, format='json')
assert resp.status_code == 201
with scopes_disabled():
assert Checkin.all.filter(successful=False).count() == 2
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/failed_checkins/'.format(
organizer.slug, event.slug, clist.pk,
), {

View File

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

View File

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

View File

@@ -52,11 +52,7 @@ 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_same_products": True,
"benefit_limit_products": [],
"benefit_apply_to_addons": True,
"benefit_ignore_voucher_discounted": False,
"benefit_only_apply_to_cheapest_n_matches": 1
}

View File

@@ -137,37 +137,6 @@ 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)
@@ -177,18 +146,8 @@ 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": "",
@@ -309,34 +268,6 @@ def test_invoice_list(token_client, organizer, event, order, item, invoice):
assert [] == resp.data['results']
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2):
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
@pytest.mark.django_db
def test_invoice_detail(token_client, organizer, event, item, invoice):
res = dict(TEST_INVOICE_RES)

View File

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

View File

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

View File

@@ -139,37 +139,6 @@ 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'])
@@ -259,7 +228,6 @@ TEST_REFUNDS_RES = [
]
TEST_ORDER_RES = {
"code": "FOO",
"event": "dummy",
"status": "n",
"testmode": False,
"secret": "k24fiuwvu8kxz3y1",
@@ -492,34 +460,6 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques
assert len(resp.data['fees']) == 2
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, order, order2):
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
@pytest.mark.django_db
def test_include_exclude_fields(token_client, organizer, event, order, item, taxrule, question):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?exclude=positions.secret'.format(

View File

@@ -97,7 +97,15 @@ 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_still_available()
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()
assert result
@@ -105,7 +113,15 @@ def test_availability_date_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_still_available()
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()
assert not result
@@ -121,9 +137,26 @@ def test_availability_date_relative(event):
))
utc = datetime.timezone.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))
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))
@pytest.mark.django_db
@@ -134,9 +167,9 @@ def test_availability_date_timezones(event):
tz = ZoneInfo('US/Pacific')
utc = ZoneInfo('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))
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
@@ -162,12 +195,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_still_available(cart_id="123")
assert prov._is_available_by_time(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_still_available(cart_id="123")
assert not prov._is_available_by_time(cart_id="123")
@pytest.mark.django_db
@@ -201,9 +234,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_still_available(order=order)
assert prov._is_available_by_time(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_still_available(order=order)
assert not prov._is_available_by_time(order=order)

View File

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

View File

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