diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 23b20d36c5..a5640ef573 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -675,6 +675,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer): raise ValidationError(errs) return data + def validate_testmode(self, testmode): + if 'sales_channel' in self.initial_data: + try: + sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']] + + if testmode and not sales_channel.testmode_supported: + raise ValidationError('This sales channel does not provide support for testmode.') + except KeyError: + # We do not need to raise a ValidationError here, since there is another check to validate the + # sales_channel + pass + + return testmode + def create(self, validated_data): fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] diff --git a/src/pretix/base/channels.py b/src/pretix/base/channels.py index 8e4855587f..0a89cc2257 100644 --- a/src/pretix/base/channels.py +++ b/src/pretix/base/channels.py @@ -35,6 +35,13 @@ class SalesChannel: """ return "circle" + @property + def testmode_supported(self) -> bool: + """ + Indication, if a saleschannels supports test mode orders + """ + return True + def get_all_sales_channels(): global _ALL_CHANNELS diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 76471e1464..11dff6e4c6 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _ from django_scopes import scopes_disabled from pretix.api.models import OAuthApplication +from pretix.base.channels import get_all_sales_channels from pretix.base.email import get_email_context from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( @@ -595,6 +596,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None): p = None + sales_channel = get_all_sales_channels()[sales_channel] + with transaction.atomic(): checked_gift_cards = [] if gift_cards: @@ -622,10 +625,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d datetime=now_dt, locale=locale, total=total, - testmode=event.testmode, + testmode=True if sales_channel.testmode_supported and event.testmode else False, meta_info=json.dumps(meta_info or {}), require_approval=any(p.item.require_approval for p in positions), - sales_channel=sales_channel + sales_channel=sales_channel.identifier ) order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) order.save() diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 00e34ab850..89c6af2851 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -247,7 +247,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): quota_cache=quota_cache, item_cache=item_cache, subevent=cartpos.subevent, - sales_channel=self.request.sales_channel + sales_channel=self.request.sales_channel.identifier ) } @@ -306,7 +306,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, data, get_or_create_cart_id(self.request), invoice_address=self.invoice_address.pk, locale=get_language(), - sales_channel=request.sales_channel) + sales_channel=request.sales_channel.identifier) class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): @@ -711,7 +711,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None, [p.id for p in self.positions], self.cart_session.get('email'), translation.get_language(), self.invoice_address.pk, meta_info, - request.sales_channel, self.cart_session.get('gift_cards'), + request.sales_channel.identifier, self.cart_session.get('gift_cards'), self.cart_session.get('shown_total')) def get_success_message(self, value): diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index d18dc273b8..336a5e285f 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -66,11 +66,19 @@
{% if request.event.testmode %} -
- - {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} - -
+ {% if request.sales_channel.testmode_supported %} +
+ + {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} + +
+ {% else %} +
+ + {% trans "Orders made through this sales channel cannot be deleted - even if the ticket shop is in test mode!" %} + +
+ {% endif %} {% endif %} {% if messages %} {% for message in messages %} @@ -82,11 +90,19 @@ {% block content %} {% endblock %} {% if request.event.testmode %} -
- - {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} - -
+ {% if request.sales_channel.testmode_supported %} +
+ + {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} + +
+ {% else %} +
+ + {% trans "Orders made through this sales channel cannot be deleted - even if the ticket shop is in test mode!" %} + +
+ {% endif %} {% endif %} {% endblock %} {% block footer %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 43b26a5940..c9bf860772 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -36,6 +36,14 @@
{{ p.provider.test_mode_message }}
+ {% if not request.event.sales_channel.testmode_supported %} +
+ {% trans "This sales channel does not provide support for testmode." %} + + {% trans "If you continue, you might pay an actual order with non-existing money!" %} + +
+ {% endif %} {% else %}
{% trans "This payment provider does not provide support for testmode." %} diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index ad5216402a..190df1c00b 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -11,6 +11,7 @@ from django.urls import resolve from django.utils.translation import ugettext_lazy as _ from django_scopes import scope +from pretix.base.channels import WebshopSalesChannel from pretix.base.middleware import LocaleMiddleware from pretix.base.models import Event, Organizer from pretix.multidomain.urlreverse import get_domain @@ -103,7 +104,7 @@ def _detect_event(request, require_live=True, require_plugin=None): if not hasattr(request, 'sales_channel'): # The environ lookup is only relevant during unit testing - request.sales_channel = request.environ.get('PRETIX_SALES_CHANNEL', 'web') + request.sales_channel = request.environ.get('PRETIX_SALES_CHANNEL', WebshopSalesChannel()) for receiver, response in process_request.send(request.event, request=request): if response: return response diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index b019e154e7..330906f6de 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -401,7 +401,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): items = self._items_from_post_data() if items: return self.do(self.request.event.id, items, cart_id, translation.get_language(), - self.invoice_address.pk, widget_data, self.request.sales_channel) + self.invoice_address.pk, widget_data, self.request.sales_channel.identifier) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ @@ -424,7 +424,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView): # Fetch all items items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent, - voucher=self.voucher, channel=self.request.sales_channel) + voucher=self.voucher, channel=self.request.sales_channel.identifier) # Calculate how many options the user still has. If there is only one option, we can # check the box right away ;) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 873d88ed42..c788e8f7db 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -294,7 +294,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): if not self.request.event.has_subevents or self.subevent: # Fetch all items items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent, - channel=self.request.sales_channel) + channel=self.request.sales_channel.identifier) context['itemnum'] = len(items) # Regroup those by category @@ -335,7 +335,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): ebd = defaultdict(list) add_subevents_for_days( - filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel).using(settings.DATABASE_REPLICA), self.request), + filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier).using(settings.DATABASE_REPLICA), self.request), before, after, ebd, set(), self.request.event, kwargs.get('cart_namespace') ) @@ -345,7 +345,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): context['years'] = range(now().year - 2, now().year + 3) else: context['subevent_list'] = self.request.event.subevents_sorted( - filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel).using(settings.DATABASE_REPLICA), self.request) + filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier).using(settings.DATABASE_REPLICA), self.request) ) context['show_cart'] = ( diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 0008dfe918..15c58399c5 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -393,7 +393,7 @@ class WidgetAPIProductList(EventListMixin, View): else: if hasattr(self.request, 'event'): evs = self.request.event.subevents_sorted( - filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel), self.request) + filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier), self.request) ) tz = pytz.timezone(request.event.settings.timezone) data['events'] = [ diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 400e56e3a8..4bcab2ad6c 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -6,6 +6,7 @@ from unittest import mock import pytest from django.core import mail as djmail +from django.dispatch import receiver from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -13,6 +14,7 @@ from pytz import UTC from stripe.error import APIConnectionError from tests.plugins.stripe.test_provider import MockedCharge +from pretix.base.channels import SalesChannel from pretix.base.models import ( InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, ) @@ -22,6 +24,21 @@ from pretix.base.models.orders import ( from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, ) +from pretix.base.signals import register_sales_channels + + +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = False + + +@receiver(register_sales_channels, dispatch_uid="test_orders_register_sales_channels") +def base_sales_channels(sender, **kwargs): + return ( + FoobarSalesChannel(), + ) @pytest.fixture @@ -1536,6 +1553,22 @@ def test_order_create_in_test_mode(token_client, organizer, event, item, quota, assert o.testmode +@pytest.mark.django_db +def test_order_create_in_test_mode_saleschannel_limited(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['testmode'] = True + res['sales_channel'] = 'bar' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'testmode': ['This sales channel does not provide support for testmode.']} + + @pytest.mark.django_db def test_order_create_attendee_name_optional(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 258b8701e5..412fa137b7 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -4,11 +4,13 @@ from decimal import Decimal import pytest import pytz from django.core import mail as djmail +from django.dispatch import receiver from django.test import TestCase from django.utils.timezone import make_aware, now from django_countries.fields import Country from django_scopes import scope +from pretix.base.channels import SalesChannel from pretix.base.decimal import round_decimal from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer, @@ -23,10 +25,25 @@ from pretix.base.services.orders import ( OrderChangeManager, OrderError, _create_order, approve_order, cancel_order, deny_order, expire_orders, send_download_reminders, send_expiry_warnings, ) +from pretix.base.signals import register_sales_channels from pretix.plugins.banktransfer.payment import BankTransfer from pretix.testutils.scope import classscope +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = False + + +@receiver(register_sales_channels, dispatch_uid="test_orders_register_sales_channels") +def base_sales_channels(sender, **kwargs): + return ( + FoobarSalesChannel(), + ) + + @pytest.fixture(scope='function') def event(): o = Organizer.objects.create(name='Dummy', slug='dummy') @@ -1960,6 +1977,38 @@ def test_autocheckin(clist_autocheckin, event): assert order.positions.first().checkins.count() == 0 +@pytest.mark.django_db +def test_saleschannel_testmode_restriction(event): + today = now() + tr7 = event.tax_rules.create(rate=Decimal('17.00')) + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr7, + default_price=Decimal('23.00'), admission=True) + cp1 = CartPosition.objects.create( + item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123" + ) + + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de', sales_channel='web')[0] + assert not order.testmode + + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de', sales_channel=FoobarSalesChannel.identifier)[0] + assert not order.testmode + + event.testmode = True + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de', sales_channel='web')[0] + assert order.testmode + + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de', sales_channel=FoobarSalesChannel.identifier)[0] + assert not order.testmode + + @pytest.mark.django_db def test_giftcard_multiple(event): ticket = Item.objects.create(event=event, name='Early-bird ticket', diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 52b438f63f..85cf8324a5 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -9,6 +9,7 @@ from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled +from pretix.base.channels import SalesChannel from pretix.base.decimal import round_decimal from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, ItemCategory, ItemVariation, @@ -24,6 +25,13 @@ from pretix.testutils.scope import classscope from pretix.testutils.sessions import get_cart_session_key +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = True + + class CartTestMixin: @scopes_disabled() def setUp(self): @@ -759,7 +767,7 @@ class CartTest(CartTestMixin, TestCase): self.ticket.save() self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 'item_%d' % self.ticket.id: '1', - }, follow=True, PRETIX_SALES_CHANNEL='bar') + }, follow=True, PRETIX_SALES_CHANNEL=FoobarSalesChannel) with scopes_disabled(): self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 00784d6a05..5bcc6696cd 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -12,6 +12,7 @@ from django_scopes import scopes_disabled from pytz import timezone from tests.base import SoupTest +from pretix.base.channels import SalesChannel from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, Organizer, Quota, Team, User, WaitingListEntry, @@ -19,6 +20,13 @@ from pretix.base.models import ( from pretix.base.models.items import SubEventItem, SubEventItemVariation +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = True + + class EventTestMixin: @scopes_disabled() def setUp(self): @@ -109,7 +117,7 @@ class ItemDisplayTest(EventTestMixin, SoupTest): q.items.add(item) html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) - html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug), PRETIX_SALES_CHANNEL="bar").rendered_content + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug), PRETIX_SALES_CHANNEL=FoobarSalesChannel).rendered_content self.assertIn("Early-bird", html) def test_timely_available(self):