diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 9eed404ff..c5ee40f67 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -15,6 +15,7 @@ name multi-lingual string The event's ful slug string A short form of the name, used e.g. in URLs. live boolean If ``true``, the event ticket shop is publicly available. +testmode boolean If ``true``, the ticket shop is in test mode. currency string The currency this event is handled in. date_from datetime The event's start date date_to datetime The event's end date (or ``null``) @@ -45,6 +46,10 @@ plugins list A list of packa Filters have been added to the list of events. +.. versionchanged:: 2.5 + + The ``testmode`` attribute has been added. + Endpoints --------- @@ -79,6 +84,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -137,6 +143,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -183,6 +190,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -211,6 +219,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -259,6 +268,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -287,6 +297,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, @@ -347,6 +358,7 @@ Endpoints "name": {"en": "Sample Conference"}, "slug": "sampleconf", "live": false, + "testmode": false, "currency": "EUR", "date_from": "2017-12-27T10:00:00Z", "date_to": null, diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 034ed16dd..421541663 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -26,6 +26,8 @@ status string Order status, o * ``p`` – paid * ``e`` – expired * ``c`` – canceled +testmode boolean If ``True``, this order was created when the event was in + test mode. Only orders in test mode can be deleted. secret string The secret contained in the link sent to the customer email string The customer email address locale string The locale used for communication with this customer @@ -131,6 +133,10 @@ last_modified datetime Last modificati ``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and ``…/mark_refunded/`` has been deprecated. +.. versionchanged:: 2.5: + + The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders. + .. _order-position-resource: Order position resource @@ -272,6 +278,7 @@ List of all orders { "code": "ABC12", "status": "p", + "testmode": false, "secret": "k24fiuwvu8kxz3y1", "email": "tester@example.org", "locale": "en", @@ -370,11 +377,14 @@ List of all orders ``status``. Default: ``datetime`` :query string code: Only return orders that match the given order code :query string status: Only return orders in the given order status (see above) + :query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false`` :query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field ``require_approval`` will be returned. :query string email: Only return orders created with the given email address :query string locale: Only return orders with the given customer locale - :query datetime modified_since: Only return orders that have changed since the given date + :query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only + recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and + you will not notice it using this method. :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch :resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch @@ -409,6 +419,7 @@ Fetching individual orders { "code": "ABC12", "status": "p", + "testmode": false, "secret": "k24fiuwvu8kxz3y1", "email": "tester@example.org", "locale": "en", @@ -552,6 +563,37 @@ Order ticket download :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +Deleting orders +--------------- + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/ + + Deletes an order. Works only if the order has ``testmode`` set to ``true``. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + Content-Type: application/json + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted. + :statuscode 404: The requested order does not exist. + Creating orders --------------- @@ -606,6 +648,7 @@ Creating orders or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will **not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call the ``mark_paid`` API method. + * ``testmode`` (optional) – Defaults to ``false`` * ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the order creation is successful. Any quotas that become free by this operation will be credited to your order creation. diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index e0ac64ab6..4279268f0 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -114,6 +114,8 @@ The provider class .. autoattribute:: is_meta + .. autoattribute:: test_mode_message + Additional views ---------------- diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 03adb3267..8fbefe59b 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -111,6 +111,7 @@ submodule subpath Symfony systemd +testmode testutils timestamp tuples diff --git a/doc/user/faq.rst b/doc/user/faq.rst index 4bde0689b..18dab0085 100644 --- a/doc/user/faq.rst +++ b/doc/user/faq.rst @@ -4,22 +4,10 @@ FAQ and Troubleshooting How can I test my shop before taking it live? --------------------------------------------- -There are multiple ways to do this. - -First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process -real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders -as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys -for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation ` for more -information. - -Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process `, -you can specify that you want to copy all settings from your real event, so you don't have to do all of it twice. - -We are planning to add a dedicated test mode in a later version of pretix. - -If you are using the hosted service at pretix.eu and want to get rid of the test orders completely, contact us at -support@pretix.eu and we can remove them for you. Please note that we only are able to do that *before* you have -received any real orders (i.e. taken the shop public). We won't charge any fees for test orders or test events. +On your event dashboard, click on the first tile that shows your shop status. On the lower part of this page, you can +place your event into "test mode". In "test mode", everything behaves the same, but orders created during test mode can +later be fully deleted. Be sure to actually delete them when or after you turn off test mode, since test mode orders +still count toward your quotas and are included in your reports. How do I delete an event? ------------------------- diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 5896f4f87..6ca15e932 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -48,7 +48,7 @@ class EventSerializer(I18nAwareModelSerializer): class Meta: model = Event - fields = ('name', 'slug', 'live', 'currency', 'date_from', + fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins') diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index c845967dc..267f31e24 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -231,7 +231,7 @@ class OrderSerializer(I18nAwareModelSerializer): class Meta: model = Order - fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', + fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel') @@ -413,7 +413,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): class Meta: model = Order - fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', + fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts') def validate_payment_provider(self, pp): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 0a09bbf31..bd98438a3 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -16,7 +16,7 @@ from rest_framework.exceptions import ( APIException, NotFound, PermissionDenied, ValidationError, ) from rest_framework.filters import OrderingFilter -from rest_framework.mixins import CreateModelMixin +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin from rest_framework.response import Response from pretix.api.models import OAuthAccessToken @@ -51,10 +51,10 @@ class OrderFilter(FilterSet): class Meta: model = Order - fields = ['code', 'status', 'email', 'locale', 'require_approval'] + fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval'] -class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): +class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer queryset = Order.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -378,6 +378,13 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): def perform_create(self, serializer): serializer.save() + def perform_destroy(self, instance): + if not instance.testmode: + raise PermissionDenied('Only test mode orders can be deleted.') + + with transaction.atomic(): + self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + class OrderPositionFilter(FilterSet): order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 05e72204b..106c161c4 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -9,7 +9,7 @@ import vat_moss.exchange_rates from django.contrib.staticfiles import finders from django.dispatch import receiver from django.utils.formats import date_format, localize -from django.utils.translation import pgettext +from django.utils.translation import pgettext, ugettext from PIL.Image import BICUBIC from reportlab.lib import pagesizes from reportlab.lib.enums import TA_LEFT @@ -267,6 +267,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): canvas.saveState() canvas.setFont(self.font_regular, 8) + if self.invoice.order.testmode: + canvas.saveState() + canvas.setFont('OpenSansBd', 30) + canvas.setFillColorRGB(32, 0, 0) + canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE')) + canvas.restoreState() + for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) diff --git a/src/pretix/base/migrations/0111_auto_20190219_0949.py b/src/pretix/base/migrations/0111_auto_20190219_0949.py new file mode 100644 index 000000000..75bea99da --- /dev/null +++ b/src/pretix/base/migrations/0111_auto_20190219_0949.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.5 on 2019-02-19 09:49 + +import django.db.models.deletion +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0110_auto_20190219_1245'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='testmode', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='event', + name='testmode', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 079416e5d..8a3f3e22b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -242,6 +242,8 @@ class Event(EventMixin, LoggedModel): :param organizer: The organizer this event belongs to :type organizer: Organizer + :param testmode: This event is in test mode + :type testmode: bool :param name: This event's full title :type name: str :param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to @@ -271,6 +273,7 @@ class Event(EventMixin, LoggedModel): settings_namespace = 'event' CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES] organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT) + testmode = models.BooleanField(default=False) name = I18nCharField( max_length=200, verbose_name=_("Event name"), diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 1ba8ae3f4..94ff604cc 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -150,6 +150,8 @@ class Invoice(models.Model): if not self.prefix: self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') if not self.invoice_no: + if self.order.testmode: + self.prefix += 'TEST-' for i in range(10): if self.event.settings.get('invoice_numbers_consecutive'): self.invoice_no = self._get_numeric_invoice_number() diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 0d7f8494f..6a8d22a0a 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -76,6 +76,8 @@ class Order(LockModel, LoggedModel): :type event: Event :param email: The email of the person who ordered this :type email: str + :param testmode: Whether this is a test mode order + :type testmode: bool :param locale: The locale of this order :type locale: str :param secret: A secret string that is required to modify the order @@ -121,6 +123,7 @@ class Order(LockModel, LoggedModel): verbose_name=_("Status"), db_index=True ) + testmode = models.BooleanField(default=False) event = models.ForeignKey( Event, verbose_name=_("Event"), @@ -185,6 +188,23 @@ class Order(LockModel, LoggedModel): def __str__(self): return self.full_code + def gracefully_delete(self, user=None, auth=None): + if not self.testmode: + raise TypeError("Only test mode orders can be deleted.") + self.event.log_action( + 'pretix.event.order.deleted', user=user, auth=auth, + data={ + 'code': self.code, + } + ) + OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() + OrderPosition.all.filter(order=self).delete() + OrderFee.all.filter(order=self).delete() + self.refunds.all().delete() + self.payments.all().delete() + self.event.cache.delete('complain_testmode_orders') + self.delete() + @property def fees(self): """ @@ -490,6 +510,10 @@ class Order(LockModel, LoggedModel): charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') while True: code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset) + if self.testmode: + # Subtle way to recognize test orders while debugging: They all contain a 0 at the second place, + # even though zeros are not used outside test mode. + code = code[0] + "0" + code[2:] if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists(): self.code = code return diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 8b5cee286..83b95013d 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -88,6 +88,18 @@ class BasePaymentProvider: """ return self.settings.get('_enabled', as_type=bool) + @property + def test_mode_message(self) -> str: + """ + If this property is set to a string, this will be displayed when this payment provider is selected + while the event is in test mode. You should use it to explain to your user how your plugin behaves, + e.g. if it falls back to a test mode automatically as well or if actual payments will be performed. + + If you do not set this (or, return ``None``), pretix will show a default message warning the user + that this plugin does not support test mode payments. + """ + return None + def calculate_fee(self, price: Decimal) -> Decimal: """ Calculate the fee for this payment provider which will be added to @@ -713,6 +725,11 @@ class ManualPayment(BasePaymentProvider): identifier = 'manual' verbose_name = _('Manual payment') + @property + def test_mode_message(self): + return _('In test mode, you can just manually mark this order as paid in the backend after it has been ' + 'created.') + @property def is_implicit(self): return 'pretix.plugins.manualpayment' not in self.event.plugins diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 65f32479d..1da26b6e8 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -130,6 +130,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString], body_plain += "\r\n\r\n-- \r\n" if order: + if order.testmode: + subject = "[TESTMODE] " + subject body_plain += _( "You are receiving this email because you placed an order for {event}." ).format(event=event.name) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index cc71c2f03..5f9235950 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -533,6 +533,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d datetime=now_dt, locale=locale, total=total, + testmode=event.testmode, meta_info=json.dumps(meta_info or {}), require_approval=any(p.item.require_approval for p in positions), sales_channel=sales_channel diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 7b20677f7..dfa0999a1 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -52,6 +52,15 @@ def contextprocessor(request): ctx['has_domain'] = request.event.organizer.domains.exists() + if not request.event.testmode: + complain_testmode_orders = request.event.cache.get('complain_testmode_orders') + if complain_testmode_orders is None: + complain_testmode_orders = request.event.orders.filter(testmode=True).exists() + request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30) + ctx['complain_testmode_orders'] = complain_testmode_orders + else: + ctx['complain_testmode_orders'] = False + if not request.event.live and ctx['has_domain']: child_sess = request.session.get('child_session_{}'.format(request.event.pk)) s = SessionStore() diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 27b7aafb2..dbb30743b 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -212,6 +212,7 @@ class EventOrderFilterForm(OrderFilterForm): ('overpaid', _('Overpaid')), ('underpaid', _('Underpaid')), ('pendingpaid', _('Pending (but fully paid)')), + ('testmode', _('Test mode')), ), required=False, ) @@ -298,6 +299,10 @@ class EventOrderFilterForm(OrderFilterForm): status=Order.STATUS_PENDING, require_approval=True ) + elif fdata.get('status') == 'testmode': + qs = qs.filter( + testmode=True + ) elif fdata.get('status') == 'cp': s = OrderPosition.objects.filter( order=OuterRef('pk') diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index ffc93bbce..4bfa1e70a 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -172,6 +172,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.paid': _('The order has been marked as paid.'), 'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.canceled': _('The order has been canceled.'), + 'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'), 'pretix.event.order.placed': _('The order has been created.'), 'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'), 'pretix.event.order.approved': _('The order has been approved.'), @@ -268,6 +269,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.plugins.disabled': _('A plugin has been disabled.'), 'pretix.event.live.activated': _('The shop has been taken live.'), 'pretix.event.live.deactivated': _('The shop has been taken offline.'), + 'pretix.event.testmode.activated': _('The shop has been taken into test mode.'), + 'pretix.event.testmode.deactivated': _('The test mode has been disabled.'), 'pretix.event.added': _('The event has been created.'), 'pretix.event.changed': _('The event settings have been changed.'), 'pretix.event.question.option.added': _('An answer option has been added to the question.'), diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 5db463ca2..9b16e07fd 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -235,6 +235,14 @@