diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index a73d4b586e..001a9f4b45 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -59,6 +59,9 @@ checkin_attention boolean If ``True``, th a product is being scanned. original_price money (string) An original price, shown for comparison, not used for price calculations. +require_approval boolean If ``True``, orders with this product will need to be + approved by the event organizer before they can be + paid. has_variations boolean Shows whether or not this item has variations. variations list of objects A list with one object for each variation of this item. Can be empty. Only writable during creation, @@ -96,7 +99,11 @@ addons list of objects Definition of a .. versionchanged:: 1.16 - The field ``internal_name`` and ``original_price`` fields have been added. + The ``internal_name`` and ``original_price`` fields have been added. + +.. versionchanged:: 2.0 + + The field ``require_approval`` has been added. Notes ----- @@ -160,6 +167,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "has_variations": false, + "require_approval": false, "variations": [ { "value": {"en": "Student"}, @@ -244,6 +252,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "has_variations": false, + "require_approval": false, "variations": [ { "value": {"en": "Student"}, @@ -308,6 +317,7 @@ Endpoints "min_per_order": null, "max_per_order": null, "checkin_attention": false, + "require_approval": false, "variations": [ { "value": {"en": "Student"}, @@ -361,6 +371,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "has_variations": true, + "require_approval": false, "variations": [ { "value": {"en": "Student"}, @@ -445,6 +456,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "has_variations": true, + "require_approval": false, "variations": [ { "value": {"en": "Student"}, diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index b59d208435..883a0784ca 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -74,6 +74,10 @@ downloads list of objects List of ticket download options. ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) └ url string Download URL +require_approval boolean If ``True`` and the order is pending, this order + needs approval by an organizer before it can + continue. If ``True`` and the order is canceled, + this order has been denied by the event organizer. payments list of objects List of payment processes (see below) refunds list of objects List of refund processes (see below) last_modified datetime Last modification of this object @@ -113,7 +117,8 @@ last_modified datetime Last modificati .. versionchanged:: 2.0 The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new - nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. + nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval`` + attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints. .. _order-position-resource: @@ -259,6 +264,7 @@ List of all orders "total": "23.00", "comment": "", "checkin_attention": false, + "require_approval": false, "invoice_address": { "last_modified": "2017-12-01T10:00:00Z", "is_business": True, @@ -339,6 +345,8 @@ 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 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 @@ -388,6 +396,7 @@ Fetching individual orders "total": "23.00", "comment": "", "checkin_attention": false, + "require_approval": false, "invoice_address": { "last_modified": "2017-12-01T10:00:00Z", "company": "Sample company", @@ -931,6 +940,85 @@ Order state operations :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order does not exist. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/approve/ + + Approve an order that is pending approval. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/approve/ 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 + + { + "code": "ABC12", + "status": "n", + "require_approval": false, + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be approved, likely because the current order status does not allow it. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/deny/ + + Marks an order that is pending approval as denied. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/deny/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: text/json + + { + "send_email": true, + "comment": "You're not a business customer!" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "code": "ABC12", + "status": "c", + "require_approval": true, + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be marked as denied since the current order status does not allow it. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + List of all order positions --------------------------- diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 4faae9c5e9..3f963086a3 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'position', 'picture', 'available_from', 'available_until', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', - 'variations', 'addons', 'original_price') + 'variations', 'addons', 'original_price', 'require_approval') read_only_fields = ('has_variations', 'picture') def get_serializer_context(self): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index edc146f362..eb8e42b21a 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -213,7 +213,7 @@ class OrderSerializer(I18nAwareModelSerializer): model = Order fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', - 'checkin_attention', 'last_modified', 'payments', 'refunds') + 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 3e283ac8f5..2454c91bd1 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -35,8 +35,8 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( - OrderChangeManager, OrderError, cancel_order, extend_order, - mark_order_expired, mark_order_refunded, + OrderChangeManager, OrderError, approve_order, cancel_order, deny_order, + extend_order, mark_order_expired, mark_order_refunded, ) from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, @@ -52,7 +52,7 @@ class OrderFilter(FilterSet): class Meta: model = Order - fields = ['code', 'status', 'email', 'locale'] + fields = ['code', 'status', 'email', 'locale', 'require_approval'] class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): @@ -182,6 +182,42 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): ) return self.retrieve(request, [], **kwargs) + @detail_route(methods=['POST']) + def approve(self, request, **kwargs): + send_mail = request.data.get('send_email', True) + + order = self.get_object() + try: + approve_order( + order, + user=request.user if request.user.is_authenticated else None, + auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None, + send_mail=send_mail, + ) + except Quota.QuotaExceededException as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except OrderError as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def deny(self, request, **kwargs): + send_mail = request.data.get('send_email', True) + comment = request.data.get('comment', '') + + order = self.get_object() + try: + deny_order( + order, + user=request.user if request.user.is_authenticated else None, + auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None, + send_mail=send_mail, + comment=comment, + ) + except OrderError as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return self.retrieve(request, [], **kwargs) + @detail_route(methods=['POST']) def mark_pending(self, request, **kwargs): order = self.get_object() diff --git a/src/pretix/base/migrations/0100_item_require_approval.py b/src/pretix/base/migrations/0100_item_require_approval.py new file mode 100644 index 0000000000..08804cec7a --- /dev/null +++ b/src/pretix/base/migrations/0100_item_require_approval.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-08-09 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0099_auto_20180807_0841'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='require_approval', + field=models.BooleanField(default=False, help_text='If this product is part of an order, the order will be put into an "approval" state and will need to be confirmed by you before it can be paid and completed. You can use this e.g. for discounted tickets that are only available to specific groups.', verbose_name='Buying this product requires approval.'), + ), + migrations.AddField( + model_name='order', + name='require_approval', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 38bc7d9ffb..ddd5552c6f 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -193,6 +193,8 @@ class Item(LoggedModel): :type checkin_attention: bool :param original_price: The item's "original" price. Will not be used for any calculations, will just be shown. :type original_price: decimal.Decimal + :param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator + :type require_approval: bool """ event = models.ForeignKey( @@ -280,6 +282,13 @@ class Item(LoggedModel): help_text=_('To buy this product, the user needs a voucher that applies to this product ' 'either directly or via a quota.') ) + require_approval = models.BooleanField( + verbose_name=_('Buying this product requires approval'), + default=False, + help_text=_('If this product is part of an order, the order will be put into an "approval" state and ' + 'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for ' + 'discounted tickets that are only available to specific groups.'), + ) hide_without_voucher = models.BooleanField( verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'), default=False, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 1134606531..82292acf57 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -88,6 +88,8 @@ class Order(LoggedModel): :type comment: str :param download_reminder_sent: A field to indicate whether a download reminder has been sent. :type download_reminder_sent: boolean + :param require_approval: If set to ``True``, this order is pending approval by an organizer + :type require_approval: bool :param meta_info: Additional meta information on the order, JSON-encoded. :type meta_info: str """ @@ -167,6 +169,9 @@ class Order(LoggedModel): last_modified = models.DateTimeField( auto_now=True, db_index=True ) + require_approval = models.BooleanField( + default=False + ) class Meta: verbose_name = _("Order") @@ -231,7 +236,10 @@ class Order(LoggedModel): then=Value('1')), When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0), then=Value('1')), - When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0), + When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lt=0), + then=Value('1')), + When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) + & Q(require_approval=False), then=Value('1')), default=Value('0'), output_field=models.IntegerField() @@ -423,7 +431,10 @@ class Order(LoggedModel): "payment settings is over."), 'late': _("The payment can not be accepted as it the order is expired and you configured that no late " "payments should be accepted in the payment settings."), + 'require_approval': _('This order is not yet approved by the event organizer.') } + if self.require_approval: + return error_messages['require_approval'] term_last = self.payment_term_last if term_last: if now() > term_last: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index c5f188a412..655cef4926 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -237,7 +237,7 @@ def invoice_pdf_task(invoice: int): def invoice_qualified(order: Order): - if order.total == Decimal('0.00'): + if order.total == Decimal('0.00') or order.require_approval: return False return True diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 81763875e2..1737403120 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -169,6 +169,142 @@ def mark_order_expired(order, user=None, auth=None): return order +@transaction.atomic +def approve_order(order, user=None, send_mail: bool=True, auth=None): + """ + Mark this order as approved + :param order: The order to change + :param user: The user that performed the change + """ + if not order.require_approval or not order.status == Order.STATUS_PENDING: + raise OrderError(_('This order is not pending approval.')) + + order.require_approval = False + order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()])) + order.save() + + order.log_action('pretix.event.order.approved', user=user, auth=auth) + if order.total == Decimal('0.00'): + p = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider='free', + amount=0, + fee=None + ) + try: + p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth) + except Quota.QuotaExceededException: + raise OrderError(error_messages['unavailable']) + + invoice = order.invoices.last() # Might be generated by plugin already + if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): + if not invoice: + generate_invoice( + order, + trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email + ) + # send_mail will trigger PDF generation later + + if send_mail: + try: + invoice_name = order.invoice_address.name + invoice_company = order.invoice_address.company + except InvoiceAddress.DoesNotExist: + invoice_name = "" + invoice_company = "" + + with language(order.locale): + if order.total == Decimal('0.00'): + email_template = order.event.settings.mail_text_order_free + email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code} + else: + email_template = order.event.settings.mail_text_order_approved + email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code} + + email_context = { + 'total': LazyNumber(order.total), + 'currency': order.event.currency, + 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), + 'date': LazyDate(order.expires), + 'event': order.event.name, + 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ + 'order': order.code, + 'secret': order.secret + }), + 'invoice_name': invoice_name, + 'invoice_company': invoice_company, + } + try: + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_approved', user + ) + except SendMailException: + logger.exception('Order approved email could not be sent') + + return order.pk + + +@transaction.atomic +def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): + """ + Mark this order as canceled + :param order: The order to change + :param user: The user that performed the change + """ + if not order.require_approval or not order.status == Order.STATUS_PENDING: + raise OrderError(_('This order is not pending approval.')) + + with order.event.lock(): + order.status = Order.STATUS_CANCELED + order.save() + + order.log_action('pretix.event.order.denied', user=user, auth=auth, data={ + 'comment': comment + }) + i = order.invoices.filter(is_cancellation=False).last() + if i: + generate_cancellation(i) + + for position in order.positions.all(): + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) + + if send_mail: + try: + invoice_name = order.invoice_address.name + invoice_company = order.invoice_address.company + except InvoiceAddress.DoesNotExist: + invoice_name = "" + invoice_company = "" + email_template = order.event.settings.mail_text_order_denied + email_context = { + 'total': LazyNumber(order.total), + 'currency': order.event.currency, + 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), + 'date': LazyDate(order.expires), + 'event': order.event.name, + 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ + 'order': order.code, + 'secret': order.secret + }), + 'comment': comment, + 'invoice_name': invoice_name, + 'invoice_company': invoice_company, + } + with language(order.locale): + email_subject = _('Order denied: %(code)s') % {'code': order.code} + try: + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_denied', user + ) + except SendMailException: + logger.exception('Order denied email could not be sent') + + return order.pk + + @transaction.atomic def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None): """ @@ -342,7 +478,10 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid meta_info: dict, event: Event): fees = [] total = sum([c.price for c in positions]) - payment_fee = payment_provider.calculate_fee(total) + if payment_provider: + payment_fee = payment_provider.calculate_fee(total) + else: + payment_fee = 0 pf = None if payment_fee: pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, @@ -370,6 +509,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d locale=locale, total=total, meta_info=json.dumps(meta_info or {}), + require_approval=any(p.item.require_approval for p in positions) ) order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) order.save() @@ -389,12 +529,13 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d fee.tax_rule = None # TODO: deprecate fee.save() - order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider=payment_provider, - amount=total, - fee=pf - ) + if payment_provider: + order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider=payment_provider, + amount=total, + fee=pf + ) OrderPosition.transform_cart_positions(positions, order) order.log_action('pretix.event.order.placed') @@ -410,9 +551,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], email: str, locale: str, address: int, meta_info: dict=None): event = Event.objects.get(id=event) - pprov = event.get_payment_providers().get(payment_provider) - if not pprov: - raise OrderError(error_messages['internal']) + if payment_provider: + pprov = event.get_payment_providers().get(payment_provider) + if not pprov: + raise OrderError(error_messages['internal']) + else: + pprov = None if email == settings.PRETIX_EMAIL_NONE_VALUE: email = None @@ -445,7 +589,10 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], # send_mail will trigger PDF generation later if order.email: - if payment_provider == 'free': + if order.require_approval: + email_template = event.settings.mail_text_order_placed_require_approval + log_entry = 'pretix.event.order.email.order_placed_require_approval' + elif payment_provider == 'free': email_template = event.settings.mail_text_order_free log_entry = 'pretix.event.order.email.order_free' else: @@ -458,6 +605,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], except InvoiceAddress.DoesNotExist: invoice_name = "" invoice_company = "" + + if pprov: + payment_info = str(pprov.order_pending_mail_render(order)) + else: + payment_info = None + email_context = { 'total': LazyNumber(order.total), 'currency': event.currency, @@ -468,7 +621,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], 'order': order.code, 'secret': order.secret }), - 'payment_info': str(pprov.order_pending_mail_render(order)), + 'payment_info': payment_info, 'invoice_name': invoice_name, 'invoice_company': invoice_company, } @@ -489,7 +642,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], def expire_orders(sender, **kwargs): eventcache = {} - for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING).select_related('event'): + for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING, + require_approval=False).select_related('event'): expire = eventcache.get(o.event.pk, None) if expire is None: expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index c9ed4e67df..bf0f95cfaf 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from typing import Any from django.conf import settings from django.core.files import File @@ -7,7 +8,6 @@ from django.db.models import Model from django.utils.translation import ugettext_noop from hierarkey.models import GlobalSettingsBase, Hierarkey from i18nfield.strings import LazyI18nString -from typing import Any from pretix.base.models.tax import TaxRule from pretix.base.reldate import RelativeDateWrapper @@ -270,8 +270,22 @@ Your {event} team""")) 'type': LazyI18nString, 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, -we successfully received your order for {event}. As you only ordered -free products, no payment is required. +your order for {event} was successful. As you only ordered free products, +no payment is required. + +You can change your order details and view the status of your order at +{url} + +Best regards, +Your {event} team""")) + }, + 'mail_text_order_placed_require_approval': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, + +we successfully received your order for {event}. Since you ordered +a product that requires approval by the event organizer, we ask you to +be patient and wait for our next email. You can change your order details and view the status of your order at {url} @@ -370,6 +384,37 @@ your order {code} for {event} has been canceled. You can view the details of your order at {url} +Best regards, +Your {event} team""")) + }, + 'mail_text_order_approved': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, + +we approved your order for {event} and will be happy to welcome you +at our event. + +Please continue by paying for your order before {date}. + +You can select a payment method and perform the payment here: + +{url} + +Best regards, +Your {event} team""")) + }, + 'mail_text_order_denied': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, + +unfortunately, we denied your order request for {event}. + +{comment} + +You can view the details of your order here: + +{url} + Best regards, Your {event} team""")) }, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 1fe4951767..a64a17897a 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -784,6 +784,34 @@ class MailSettingsForm(SettingsForm): help_text=_("This email will be sent out this many days before the order event starts. If the " "field is empty, the mail will never be sent.") ) + mail_text_order_placed_require_approval = I18nFormField( + label=_("Received order"), + required=False, + widget=I18nTextarea, + help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, " + "{url}, {invoice_name}, {invoice_company}"), + validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', + '{url}', '{invoice_name}', '{invoice_company}'])] + ) + mail_text_order_approved = I18nFormField( + label=_("Approved order"), + required=False, + widget=I18nTextarea, + help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order " + "template from above instead. Available placeholders: {event}, {total_with_currency}, {total}, " + "{currency}, {date}, {payment_info}, {url}, {invoice_name}, {invoice_company}"), + validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', + '{url}', '{invoice_name}', '{invoice_company}'])] + ) + mail_text_order_denied = I18nFormField( + label=_("Denied order"), + required=False, + widget=I18nTextarea, + help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, " + "{comment}, {url}, {invoice_name}, {invoice_company}"), + validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}', + '{comment}', '{url}', '{invoice_name}', '{invoice_company}'])] + ) smtp_use_custom = forms.BooleanField( label=_("Use custom SMTP server"), help_text=_("All mail related to your event will be sent over the smtp server specified by you."), diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index bc8b20ead7..a340cdae50 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -206,6 +206,7 @@ class EventOrderFilterForm(OrderFilterForm): ('ne', _('Pending or expired')), ('c', _('Canceled')), ('r', _('Refunded')), + ('pa', _('Approval pending')), ('overpaid', _('Overpaid')), ('underpaid', _('Underpaid')), ), @@ -275,13 +276,20 @@ class EventOrderFilterForm(OrderFilterForm): qs = qs.filter( Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0)) | Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0)) - | Q(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)) + | Q(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lt=0)) + | Q(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) + & Q(require_approval=False)) ) elif fdata.get('status') == 'underpaid': qs = qs.filter( status=Order.STATUS_PAID, pending_sum_t__gt=0 ) + elif fdata.get('status') == 'pa': + qs = qs.filter( + status=Order.STATUS_PENDING, + require_approval=True + ) return qs diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 298702e343..4661b17bd4 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -321,6 +321,7 @@ class ItemUpdateForm(I18nModelForm): 'available_from', 'available_until', 'require_voucher', + 'require_approval', 'hide_without_voucher', 'allow_cancel', 'max_per_order', diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index f2beb15c65..f142256861 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -165,6 +165,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.canceled': _('The order has been canceled.'), 'pretix.event.order.placed': _('The order has been created.'), + 'pretix.event.order.approved': _('The order has been approved.'), + 'pretix.event.order.denied': _('The order has been denied.'), 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" ' 'to "{new_email}".'), 'pretix.event.order.locale.changed': _('The order locale has been changed.'), @@ -185,7 +187,13 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.email.order_changed': _('An email has been sent to notify the user that the order has been changed.'), 'pretix.event.order.email.order_free': _('An email has been sent to notify the user that the order has been received.'), 'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'), + 'pretix.event.order.email.order_denied': _('An email has been sent to notify the user that the order has been denied.'), + 'pretix.event.order.email.order_approved': _('An email has been sent to notify the user that the order has ' + 'been approved.'), 'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'), + 'pretix.event.order.email.order_placed_require_approval': _('An email has been sent to notify the user that ' + 'the order has been received and requires ' + 'approval.'), 'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'), 'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'), 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index c391b78a22..0059716859 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -34,6 +34,15 @@ class="btn btn-primary">{% trans "Show pending refunds" %} {% endif %} + {% if has_pending_approvals %} +
+ {% blocktrans trimmed %} + This event contains pending approvals that you should take care of. + {% endblocktrans %} + {% trans "Show orders pending approval" %} +
+ {% endif %} {% if actions|length > 0 %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html index 9044ced8f8..2e2350117a 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail.html @@ -45,6 +45,9 @@ {% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder" exclude="mail_days_download_reminder" %} + + {% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %} + {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_denied" %}
diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 07e89d809f..1306a83ab4 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -41,6 +41,7 @@
{% trans "Additional settings" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} + {% bootstrap_field form.require_approval layout="control" %} {% for f in plugin_forms %} {% bootstrap_form f layout="control" %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/order/approve.html b/src/pretix/control/templates/pretixcontrol/order/approve.html new file mode 100644 index 0000000000..ef9e19b18f --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/approve.html @@ -0,0 +1,31 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% block title %} + {% trans "Approve order" %} +{% endblock %} +{% block content %} +

+ {% trans "Approve order" %} +

+

{% blocktrans trimmed %} + Do you really want to approve this order? + {% endblocktrans %}

+ +
+ {% csrf_token %} +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/deny.html b/src/pretix/control/templates/pretixcontrol/order/deny.html new file mode 100644 index 0000000000..a139909ea0 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/deny.html @@ -0,0 +1,41 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% block title %} + {% trans "Deny order" %} +{% endblock %} +{% block content %} +

+ {% trans "Deny order" %} +

+

{% blocktrans trimmed %} + Do you really want to cancel this order? You cannot revert this action. + {% endblocktrans %}

+ +
+ {% csrf_token %} +
+ +
+

+ + +

+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 7512419d83..57b09e7588 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -24,27 +24,40 @@ {% csrf_token %}
-
-
- {% if payment_provider.identifier != "free" %} - - {% endif %} -

- {% trans "Payment" %} -

+ {% if payment_provider %} +
+
+ {% if payment_provider.identifier != "free" %} + + {% endif %} +

+ {% trans "Payment" %} +

+
+
+ {{ payment }} +
-
- {{ payment }} -
-
+ {% endif %} {% eventsignal event "pretix.presale.signals.checkout_confirm_page_content" request=request %}
{% if request.event.settings.invoice_address_asked %} @@ -155,6 +157,17 @@
{% endif %} + {% if require_approval %} +
+ + {% trans "Your order requires approval by the event organizer before it can be confirmed and forms a valid contract." %} + + {% blocktrans trimmed %} + We will sent you an email as soon as the event organizer approved or rejected your order. If your + order was approved, we will send you a link that you can use to pay. + {% endblocktrans %} +
+ {% endif %}
{% trans "Payment pending" %} + {% if order.require_approval %} + {% trans "Approval pending" %} + {% else %} + {% trans "Payment pending" %} + {% endif %} {% elif order.status == "p" %} {% trans "Paid" %} {% elif order.status == "e" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 2a3a46d245..9153fc7ab9 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -14,9 +14,15 @@ {% if order.status != 'p' %}

{% trans "Your order has been placed successfully. See below for details." %}
- - {% trans "Please note that we still await your payment to complete the process." %} - + {% if order.require_approval %} + + {% trans "Please note that we still await approval by the event organizer before you can pay and complete this order." %} + + {% else %} + + {% trans "Please note that we still await your payment to complete the process." %} + + {% endif %}

{% elif order.total == 0 %}

{% trans "Your order has been processed successfully! See below for details." %}

@@ -41,7 +47,7 @@ {% include "pretixpresale/event/fragment_order_status.html" with order=order class="pull-right" %}
- {% if order.status == "n" %} + {% if order.status == "n" and not order.require_approval %}

diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index e0365ae1f2..dbfd8a26f2 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -147,7 +147,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView): if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed: ctx['can_pay'] = False - ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() + ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True elif self.order.status == Order.STATUS_PAID: ctx['can_pay'] = False @@ -168,7 +168,8 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView): raise Http404(_('Unknown order code or not authorized to access this order.')) if (self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) or self.payment.state != OrderPayment.PAYMENT_STATE_CREATED - or not self.payment.payment_provider.is_enabled): + or not self.payment.payment_provider.is_enabled + or self.order._can_be_paid() is not True): messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) @@ -229,7 +230,7 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView): self.request = request if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED: + if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or not self.order._can_be_paid(): messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) if (not self.payment.payment_provider.payment_is_valid_session(request) or @@ -286,7 +287,7 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View): self.request = request if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED: + if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or not self.order._can_be_paid(): messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) if (not self.payment.payment_provider.payment_is_valid_session(request) or @@ -329,7 +330,7 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): self.request = request if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) or not self.order._can_be_paid(): messages.error(request, _('The payment method for this order cannot be changed.')) return redirect(self.get_order_url()) diff --git a/src/pretix/static/pretixbase/scss/_theme.scss b/src/pretix/static/pretixbase/scss/_theme.scss index 138a731a11..cae4fcb5ee 100644 --- a/src/pretix/static/pretixbase/scss/_theme.scss +++ b/src/pretix/static/pretixbase/scss/_theme.scss @@ -137,6 +137,12 @@ font-size: 22px; padding-top: 14px; } +.alert-primary::before { + background: $brand-primary !important; +} +.alert-primary { + border-color: $brand-primary !important; +} .progress-bar { box-shadow: none; diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 4646dd81e0..72c365a83f 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -230,6 +230,7 @@ TEST_ITEM_RES = { "max_per_order": None, "checkin_attention": False, "has_variations": False, + "require_approval": False, "variations": [], "addons": [], "original_price": None diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index e630c4bd77..0bacb78cb9 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -203,6 +203,7 @@ TEST_ORDER_RES = { "vat_id": "", "vat_id_validated": False }, + "require_approval": False, "positions": [TEST_ORDERPOSITION_RES], "downloads": [], "payments": TEST_PAYMENTS_RES, @@ -1142,6 +1143,80 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order, assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" +@pytest.mark.django_db +def test_order_pending_approve(token_client, organizer, event, order): + order.require_approval = True + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/approve/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_PENDING + assert not resp.data['require_approval'] + + +@pytest.mark.django_db +def test_order_invalid_state_approve(token_client, organizer, event, order): + order.require_approval = True + order.status = Order.STATUS_CANCELED + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/approve/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + + order.require_approval = False + order.status = Order.STATUS_PENDING + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/approve/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_order_pending_deny(token_client, organizer, event, order): + order.require_approval = True + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/deny/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_CANCELED + assert resp.data['require_approval'] + + +@pytest.mark.django_db +def test_order_invalid_state_deny(token_client, organizer, event, order): + order.require_approval = True + order.status = Order.STATUS_CANCELED + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/deny/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + + order.require_approval = False + order.status = Order.STATUS_PENDING + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/deny/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + + ORDER_CREATE_PAYLOAD = { "email": "dummy@dummy.test", "locale": "en", diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index eeab272fa5..ba6ca2d5cb 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -73,6 +73,8 @@ event_permission_sub_urls = [ ('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_expired/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_canceled/', 404), + ('post', 'can_change_orders', 'orders/ABC12/approve/', 404), + ('post', 'can_change_orders', 'orders/ABC12/deny/', 404), ('post', 'can_change_orders', 'orders/ABC12/extend/', 400), ('get', 'can_view_orders', 'orders/ABC12/payments/', 404), ('get', 'can_view_orders', 'orders/ABC12/payments/1/', 404), diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index ef71d9a163..b11b787205 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -18,8 +18,8 @@ from pretix.base.payment import FreeOrderProvider from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.invoices import generate_invoice from pretix.base.services.orders import ( - OrderChangeManager, OrderError, _create_order, expire_orders, - send_download_reminders, + OrderChangeManager, OrderError, _create_order, approve_order, deny_order, + expire_orders, send_download_reminders, ) @@ -207,6 +207,88 @@ def test_expiring_auto_disabled(event): assert o2.status == Order.STATUS_PENDING +@pytest.mark.django_db +def test_do_not_expire_if_approval_pending(event): + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=0, require_approval=True + ) + o2 = Order.objects.create( + code='FO2', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=0, + ) + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_approve(event): + djmail.outbox = [] + event.settings.invoice_generate = 'True' + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=10, require_approval=True, locale='de' + ) + approve_order(o1) + o1.refresh_from_db() + assert o1.expires > now() + assert o1.status == Order.STATUS_PENDING + assert not o1.require_approval + assert o1.invoices.count() == 1 + assert len(djmail.outbox) == 1 + assert 'awaiting payment' in djmail.outbox[0].subject + + +@pytest.mark.django_db +def test_approve_free(event): + djmail.outbox = [] + event.settings.invoice_generate = 'True' + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=0, require_approval=True + ) + approve_order(o1) + o1.refresh_from_db() + assert o1.expires > now() + assert o1.status == Order.STATUS_PAID + assert not o1.require_approval + assert o1.invoices.count() == 0 + assert len(djmail.outbox) == 1 + assert 'confirmed' in djmail.outbox[0].subject + + +@pytest.mark.django_db +def test_deny(event): + djmail.outbox = [] + event.settings.invoice_generate = 'True' + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=10, require_approval=True, locale='de' + ) + generate_invoice(o1) + deny_order(o1) + o1.refresh_from_db() + assert o1.expires < now() + assert o1.status == Order.STATUS_CANCELED + assert o1.require_approval + assert o1.invoices.count() == 2 + assert len(djmail.outbox) == 1 + assert 'denied' in djmail.outbox[0].subject + + class DownloadReminderTests(TestCase): def setUp(self): super().setUp() diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 789a68ca6f..56f2b8b47c 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -92,6 +92,13 @@ def test_order_list(client, env): response = client.get('/control/event/dummy/dummy/orders/?status=o') assert 'FOO' in response.rendered_content + response = client.get('/control/event/dummy/dummy/orders/?status=pa') + assert 'FOO' not in response.rendered_content + env[2].require_approval = True + env[2].save() + response = client.get('/control/event/dummy/dummy/orders/?status=pa') + assert 'FOO' in response.rendered_content + q = Question.objects.create(event=env[0], question="Q", type="N", required=True) q.items.add(env[3]) op = env[2].positions.first() @@ -208,6 +215,40 @@ def test_order_transition_to_paid_expired_quota_left(client, env): assert o.status == Order.STATUS_PAID +@pytest.mark.django_db +def test_order_approve(client, env): + o = Order.objects.get(id=env[2].id) + o.status = Order.STATUS_PENDING + o.require_approval = True + o.save() + q = Quota.objects.create(event=env[0], size=10) + q.items.add(env[3]) + client.login(email='dummy@dummy.dummy', password='dummy') + res = client.post('/control/event/dummy/dummy/orders/FOO/approve', { + }) + o = Order.objects.get(id=env[2].id) + assert res.status_code < 400 + assert o.status == Order.STATUS_PENDING + assert not o.require_approval + + +@pytest.mark.django_db +def test_order_deny(client, env): + o = Order.objects.get(id=env[2].id) + o.status = Order.STATUS_PENDING + o.require_approval = True + o.save() + q = Quota.objects.create(event=env[0], size=10) + q.items.add(env[3]) + client.login(email='dummy@dummy.dummy', password='dummy') + res = client.post('/control/event/dummy/dummy/orders/FOO/deny', { + }) + o = Order.objects.get(id=env[2].id) + assert res.status_code < 400 + assert o.status == Order.STATUS_CANCELED + assert o.require_approval + + @pytest.mark.django_db @pytest.mark.parametrize("process", [ # (Old status, new status, success expected) diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 404a0410ef..f99d9524c1 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -102,6 +102,8 @@ event_urls = [ "orders/ABC/contact", "orders/ABC/comment", "orders/ABC/locale", + "orders/ABC/approve", + "orders/ABC/deny", "orders/ABC/checkvatid", "orders/ABC/payments/1/cancel", "orders/ABC/payments/1/confirm", @@ -257,6 +259,8 @@ event_permission_urls = [ ("can_change_orders", "orders/FOO/resend", 405), ("can_change_orders", "orders/FOO/invoice", 405), ("can_change_orders", "orders/FOO/change", 200), + ("can_change_orders", "orders/FOO/approve", 200), + ("can_change_orders", "orders/FOO/deny", 200), ("can_change_orders", "orders/FOO/comment", 405), ("can_change_orders", "orders/FOO/locale", 200), ("can_view_orders", "orders/FOO/answer/5/", 404), diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 9d0793ad29..4658af6845 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -146,6 +146,8 @@ def logged_in_client(client, event): ('/control/event/{orga}/{event}/orders/{order_code}/comment', 405), ('/control/event/{orga}/{event}/orders/{order_code}/change', 200), ('/control/event/{orga}/{event}/orders/{order_code}/locale', 200), + ('/control/event/{orga}/{event}/orders/{order_code}/approve', 200), + ('/control/event/{orga}/{event}/orders/{order_code}/deny', 200), ('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/cancel', 200), ('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/confirm', 200), ('/control/event/{orga}/{event}/orders/{order_code}/refund', 200), diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 8d7975d279..4c298748a9 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -14,7 +14,7 @@ from django_countries.fields import Country from pretix.base.decimal import round_decimal from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Item, ItemCategory, Order, + CartPosition, Event, Invoice, InvoiceAddress, Item, ItemCategory, Order, OrderPosition, Organizer, Question, QuestionAnswer, Quota, Voucher, ) from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem @@ -803,6 +803,43 @@ class CheckoutTestCase(TestCase): self.assertEqual(OrderPosition.objects.count(), 1) self.assertEqual(OrderPosition.objects.first().subevent, se) + def test_require_approval_no_payment_step(self): + self.event.settings.invoice_generate = 'True' + self.ticket.require_approval = True + self.ticket.save() + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=42, expires=now() + timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + print(doc) + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(Order.objects.first().status, Order.STATUS_PENDING) + self.assertTrue(Order.objects.first().require_approval) + self.assertEqual(OrderPosition.objects.count(), 1) + self.assertEqual(Invoice.objects.count(), 0) + + def test_require_approval_no_payment_step_free(self): + self.ticket.require_approval = True + self.ticket.save() + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=0, expires=now() + timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(Order.objects.first().status, Order.STATUS_PENDING) + self.assertTrue(Order.objects.first().require_approval) + self.assertEqual(OrderPosition.objects.count(), 1) + def test_free_price(self): self.ticket.free_price = True self.ticket.save()