Merge pull request #989 from pretix/approvals

Require approval for orders of specific products
This commit is contained in:
Raphael Michel
2018-08-14 17:12:32 +02:00
committed by GitHub
40 changed files with 955 additions and 85 deletions

View File

@@ -59,6 +59,9 @@ checkin_attention boolean If ``True``, th
a product is being scanned. a product is being scanned.
original_price money (string) An original price, shown for comparison, not used original_price money (string) An original price, shown for comparison, not used
for price calculations. 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. 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. variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation, Can be empty. Only writable during creation,
@@ -96,7 +99,11 @@ addons list of objects Definition of a
.. versionchanged:: 1.16 .. 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 Notes
----- -----
@@ -160,6 +167,7 @@ Endpoints
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"require_approval": false,
"variations": [ "variations": [
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},
@@ -244,6 +252,7 @@ Endpoints
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"require_approval": false,
"variations": [ "variations": [
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},
@@ -308,6 +317,7 @@ Endpoints
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
"require_approval": false,
"variations": [ "variations": [
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},
@@ -361,6 +371,7 @@ Endpoints
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false,
"variations": [ "variations": [
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},
@@ -445,6 +456,7 @@ Endpoints
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false,
"variations": [ "variations": [
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},

View File

@@ -74,6 +74,10 @@ downloads list of objects List of ticket
download options. download options.
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL └ 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) payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below) refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object last_modified datetime Last modification of this object
@@ -113,7 +117,8 @@ last_modified datetime Last modificati
.. versionchanged:: 2.0 .. versionchanged:: 2.0
The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new 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: .. _order-position-resource:
@@ -259,6 +264,7 @@ List of all orders
"total": "23.00", "total": "23.00",
"comment": "", "comment": "",
"checkin_attention": false, "checkin_attention": false,
"require_approval": false,
"invoice_address": { "invoice_address": {
"last_modified": "2017-12-01T10:00:00Z", "last_modified": "2017-12-01T10:00:00Z",
"is_business": True, "is_business": True,
@@ -339,6 +345,8 @@ List of all orders
``status``. Default: ``datetime`` ``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code :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 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 email: Only return orders created with the given email address
:query string locale: Only return orders with the given customer locale :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
@@ -388,6 +396,7 @@ Fetching individual orders
"total": "23.00", "total": "23.00",
"comment": "", "comment": "",
"checkin_attention": false, "checkin_attention": false,
"require_approval": false,
"invoice_address": { "invoice_address": {
"last_modified": "2017-12-01T10:00:00Z", "last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company", "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 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 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 List of all order positions
--------------------------- ---------------------------

View File

@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'position', 'picture', 'available_from', 'available_until', 'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', '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') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self): def get_serializer_context(self):

View File

@@ -213,7 +213,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', '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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@@ -35,8 +35,8 @@ from pretix.base.services.invoices import (
) )
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, cancel_order, extend_order, OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded,
) )
from pretix.base.services.tickets import ( from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position, get_cachedticket_for_order, get_cachedticket_for_position,
@@ -52,7 +52,7 @@ class OrderFilter(FilterSet):
class Meta: class Meta:
model = Order model = Order
fields = ['code', 'status', 'email', 'locale'] fields = ['code', 'status', 'email', 'locale', 'require_approval']
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@@ -182,6 +182,42 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
) )
return self.retrieve(request, [], **kwargs) 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']) @detail_route(methods=['POST'])
def mark_pending(self, request, **kwargs): def mark_pending(self, request, **kwargs):
order = self.get_object() order = self.get_object()

View File

@@ -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),
),
]

View File

@@ -193,6 +193,8 @@ class Item(LoggedModel):
:type checkin_attention: bool :type checkin_attention: bool
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown. :param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal :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( 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 ' help_text=_('To buy this product, the user needs a voucher that applies to this product '
'either directly or via a quota.') '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( hide_without_voucher = models.BooleanField(
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'), verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False, default=False,

View File

@@ -88,6 +88,8 @@ class Order(LoggedModel):
:type comment: str :type comment: str
:param download_reminder_sent: A field to indicate whether a download reminder has been sent. :param download_reminder_sent: A field to indicate whether a download reminder has been sent.
:type download_reminder_sent: boolean :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. :param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str :type meta_info: str
""" """
@@ -167,6 +169,9 @@ class Order(LoggedModel):
last_modified = models.DateTimeField( last_modified = models.DateTimeField(
auto_now=True, db_index=True auto_now=True, db_index=True
) )
require_approval = models.BooleanField(
default=False
)
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
@@ -231,7 +236,10 @@ class Order(LoggedModel):
then=Value('1')), then=Value('1')),
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0), When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
then=Value('1')), 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')), then=Value('1')),
default=Value('0'), default=Value('0'),
output_field=models.IntegerField() output_field=models.IntegerField()
@@ -423,7 +431,10 @@ class Order(LoggedModel):
"payment settings is over."), "payment settings is over."),
'late': _("The payment can not be accepted as it the order is expired and you configured that no late " '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."), "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 term_last = self.payment_term_last
if term_last: if term_last:
if now() > term_last: if now() > term_last:

View File

@@ -237,7 +237,7 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order): def invoice_qualified(order: Order):
if order.total == Decimal('0.00'): if order.total == Decimal('0.00') or order.require_approval:
return False return False
return True return True

View File

@@ -169,6 +169,142 @@ def mark_order_expired(order, user=None, auth=None):
return order 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 @transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None): 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): meta_info: dict, event: Event):
fees = [] fees = []
total = sum([c.price for c in positions]) 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 pf = None
if payment_fee: if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=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, locale=locale,
total=total, total=total,
meta_info=json.dumps(meta_info or {}), 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.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save() 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.tax_rule = None # TODO: deprecate
fee.save() fee.save()
order.payments.create( if payment_provider:
state=OrderPayment.PAYMENT_STATE_CREATED, order.payments.create(
provider=payment_provider, state=OrderPayment.PAYMENT_STATE_CREATED,
amount=total, provider=payment_provider,
fee=pf amount=total,
) fee=pf
)
OrderPosition.transform_cart_positions(positions, order) OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed') 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): email: str, locale: str, address: int, meta_info: dict=None):
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
pprov = event.get_payment_providers().get(payment_provider) if payment_provider:
if not pprov: pprov = event.get_payment_providers().get(payment_provider)
raise OrderError(error_messages['internal']) if not pprov:
raise OrderError(error_messages['internal'])
else:
pprov = None
if email == settings.PRETIX_EMAIL_NONE_VALUE: if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None 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 # send_mail will trigger PDF generation later
if order.email: 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 email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free' log_entry = 'pretix.event.order.email.order_free'
else: else:
@@ -458,6 +605,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
invoice_name = "" invoice_name = ""
invoice_company = "" invoice_company = ""
if pprov:
payment_info = str(pprov.order_pending_mail_render(order))
else:
payment_info = None
email_context = { email_context = {
'total': LazyNumber(order.total), 'total': LazyNumber(order.total),
'currency': event.currency, 'currency': event.currency,
@@ -468,7 +621,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret
}), }),
'payment_info': str(pprov.order_pending_mail_render(order)), 'payment_info': payment_info,
'invoice_name': invoice_name, 'invoice_name': invoice_name,
'invoice_company': invoice_company, '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): def expire_orders(sender, **kwargs):
eventcache = {} 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) expire = eventcache.get(o.event.pk, None)
if expire is None: if expire is None:
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool) expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Any
from django.conf import settings from django.conf import settings
from django.core.files import File from django.core.files import File
@@ -7,7 +8,6 @@ from django.db.models import Model
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from hierarkey.models import GlobalSettingsBase, Hierarkey from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from typing import Any
from pretix.base.models.tax import TaxRule from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
@@ -270,8 +270,22 @@ Your {event} team"""))
'type': LazyI18nString, 'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
we successfully received your order for {event}. As you only ordered your order for {event} was successful. As you only ordered free products,
free products, no payment is required. 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 You can change your order details and view the status of your order at
{url} {url}
@@ -370,6 +384,37 @@ your order {code} for {event} has been canceled.
You can view the details of your order at You can view the details of your order at
{url} {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, Best regards,
Your {event} team""")) Your {event} team"""))
}, },

View File

@@ -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 " 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.") "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( smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"), label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."), help_text=_("All mail related to your event will be sent over the smtp server specified by you."),

View File

@@ -206,6 +206,7 @@ class EventOrderFilterForm(OrderFilterForm):
('ne', _('Pending or expired')), ('ne', _('Pending or expired')),
('c', _('Canceled')), ('c', _('Canceled')),
('r', _('Refunded')), ('r', _('Refunded')),
('pa', _('Approval pending')),
('overpaid', _('Overpaid')), ('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')), ('underpaid', _('Underpaid')),
), ),
@@ -275,13 +276,20 @@ class EventOrderFilterForm(OrderFilterForm):
qs = qs.filter( 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_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_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': elif fdata.get('status') == 'underpaid':
qs = qs.filter( qs = qs.filter(
status=Order.STATUS_PAID, status=Order.STATUS_PAID,
pending_sum_t__gt=0 pending_sum_t__gt=0
) )
elif fdata.get('status') == 'pa':
qs = qs.filter(
status=Order.STATUS_PENDING,
require_approval=True
)
return qs return qs

View File

@@ -321,6 +321,7 @@ class ItemUpdateForm(I18nModelForm):
'available_from', 'available_from',
'available_until', 'available_until',
'require_voucher', 'require_voucher',
'require_approval',
'hide_without_voucher', 'hide_without_voucher',
'allow_cancel', 'allow_cancel',
'max_per_order', 'max_per_order',

View File

@@ -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.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'), 'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.placed': _('The order has been created.'), '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}" ' 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'), 'to "{new_email}".'),
'pretix.event.order.locale.changed': _('The order locale has been changed.'), '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_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_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_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': _('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.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.confirmed': _('Payment {local_id} has been confirmed.'),
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),

View File

@@ -34,6 +34,15 @@
class="btn btn-primary">{% trans "Show pending refunds" %}</a> class="btn btn-primary">{% trans "Show pending refunds" %}</a>
</div> </div>
{% endif %} {% endif %}
{% if has_pending_approvals %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This event contains <strong>pending approvals</strong> that you should take care of.
{% endblocktrans %}
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=pa"
class="btn btn-primary">{% trans "Show orders pending approval" %}</a>
</div>
{% endif %}
{% if actions|length > 0 %} {% if actions|length > 0 %}
<div class="panel panel-danger"> <div class="panel panel-danger">
<div class="panel-heading"> <div class="panel-heading">

View File

@@ -45,6 +45,9 @@
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %} {% 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" %} {% 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" %}
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>

View File

@@ -41,6 +41,7 @@
<fieldset> <fieldset>
<legend>{% trans "Additional settings" %}</legend> <legend>{% trans "Additional settings" %}</legend>
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}
{% for f in plugin_forms %} {% for f in plugin_forms %}
{% bootstrap_form f layout="control" %} {% bootstrap_form f layout="control" %}
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,31 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Approve order" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Approve order" %}
</h1>
<p>{% blocktrans trimmed %}
Do you really want to approve this order?
{% endblocktrans %}</p>
<form method="post" href="">
{% csrf_token %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "No, take me back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Yes, approve order" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Deny order" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Deny order" %}
</h1>
<p>{% blocktrans trimmed %}
Do you really want to cancel this order? You cannot revert this action.
{% endblocktrans %}</p>
<form method="post" href="">
{% csrf_token %}
<div class="checkbox">
<label>
<input type="checkbox" name="send_email" value="on" checked="checked">
{% trans "Notify user by e-mail" %}
</label>
</div>
<p>
<label>{% trans "Comment (will be sent to the user)" %}</label>
<textarea name="comment" class="form-control" rows="5"></textarea>
</p>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "No, take me back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Yes, deny order" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -24,27 +24,40 @@
{% csrf_token %} {% csrf_token %}
<div class="btn-toolbar" role="toolbar"> <div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
{% if order.status == 'n' or order.status == 'e' %} {% if order.require_approval and order.status == 'n' %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p" <a href="{% url "control:event.order.approve" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"
class="btn {% if overpaid >= 0 %}btn-primary{% else %}btn-default{% endif %}"> class="btn btn-primary">
{% trans "Mark as paid" %} <span class="fa fa-thumbs-up"></span>
{% trans "Approve" %}
</a> </a>
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default"> <a href="{% url "control:event.order.deny" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"
{% trans "Extend payment term" %} class="btn btn-danger">
</a> <span class="fa fa-thumbs-down"></span>
{% endif %} {% trans "Deny" %}
{% if order.cancel_allowed %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
{% trans "Cancel order" %}
</a>
{% endif %}
{% if order.status == 'p' %}
<button name="status" value="n" class="btn btn-default">{% trans "Mark as not paid" %}</button>
{% endif %}
{% if overpaid|add:order.total != 0 %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a> </a>
{% else %}
{% if order.status == 'n' or order.status == 'e' %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p"
class="btn {% if overpaid >= 0 %}btn-primary{% else %}btn-default{% endif %}">
{% trans "Mark as paid" %}
</a>
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Extend payment term" %}
</a>
{% endif %}
{% if order.cancel_allowed %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
{% trans "Cancel order" %}
</a>
{% endif %}
{% if order.status == 'p' %}
<button name="status" value="n" class="btn btn-default">{% trans "Mark as not paid" %}</button>
{% endif %}
{% if overpaid|add:order.total != 0 %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
{% endif %}
{% endif %} {% endif %}
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}" <a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"

View File

@@ -1,7 +1,11 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% if order.status == "n" %} {% if order.status == "n" %}
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span> {% if order.require_approval %}
<span class="label label-warning {{ class }}">{% trans "Approval pending" %}</span>
{% else %}
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span>
{% endif %}
{% elif order.status == "p" %} {% elif order.status == "p" %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span> <span class="label label-success {{ class }}">{% trans "Paid" %}</span>
{% elif order.status == "e" %} {# expired #} {% elif order.status == "e" %} {# expired #}

View File

@@ -192,6 +192,10 @@ urlpatterns = [
name='event.order.comment'), name='event.order.comment'),
url(r'^orders/(?P<code>[0-9A-Z]+)/change$', orders.OrderChange.as_view(), url(r'^orders/(?P<code>[0-9A-Z]+)/change$', orders.OrderChange.as_view(),
name='event.order.change'), name='event.order.change'),
url(r'^orders/(?P<code>[0-9A-Z]+)/approve', orders.OrderApprove.as_view(),
name='event.order.approve'),
url(r'^orders/(?P<code>[0-9A-Z]+)/deny$', orders.OrderDeny.as_view(),
name='event.order.deny'),
url(r'^orders/(?P<code>[0-9A-Z]+)/info', orders.OrderModifyInformation.as_view(), url(r'^orders/(?P<code>[0-9A-Z]+)/info', orders.OrderModifyInformation.as_view(),
name='event.order.info'), name='event.order.info'),
url(r'^orders/(?P<code>[0-9A-Z]+)/sendmail$', orders.OrderSendMail.as_view(), url(r'^orders/(?P<code>[0-9A-Z]+)/sendmail$', orders.OrderSendMail.as_view(),

View File

@@ -269,12 +269,18 @@ def event_index(request, organizer, event):
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter( ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).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_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_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))
).exists() ).exists()
ctx['has_pending_refunds'] = OrderRefund.objects.filter( ctx['has_pending_refunds'] = OrderRefund.objects.filter(
order__event=request.event, order__event=request.event,
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL) state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL)
) ).exists()
ctx['has_pending_approvals'] = request.event.orders.filter(
status=Order.STATUS_PENDING,
require_approval=True
).exists()
for a in ctx['actions']: for a in ctx['actions']:
a.display = a.display(request) a.display = a.display(request)

View File

@@ -541,7 +541,13 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
'mail_text_order_canceled': ['code', 'event', 'url'], 'mail_text_order_canceled': ['code', 'event', 'url'],
'mail_text_order_custom_mail': ['expire_date', 'event', 'code', 'date', 'url', 'mail_text_order_custom_mail': ['expire_date', 'event', 'code', 'date', 'url',
'invoice_name', 'invoice_company'], 'invoice_name', 'invoice_company'],
'mail_text_download_reminder': ['event', 'url'] 'mail_text_download_reminder': ['event', 'url'],
'mail_text_order_placed_require_approval': ['total', 'currency', 'date', 'invoice_company',
'total_with_currency', 'event', 'url', 'invoice_name'],
'mail_text_order_approved': ['total', 'currency', 'date', 'invoice_company',
'total_with_currency', 'event', 'url', 'invoice_name'],
'mail_text_order_denied': ['total', 'currency', 'date', 'invoice_company',
'total_with_currency', 'event', 'url', 'invoice_name'],
} }
@cached_property @cached_property
@@ -566,6 +572,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
'code': '68CYU2H6ZTP3WLK5', 'code': '68CYU2H6ZTP3WLK5',
'invoice_name': _('John Doe'), 'invoice_name': _('John Doe'),
'invoice_company': _('Sample Corporation'), 'invoice_company': _('Sample Corporation'),
'common': _('An individial text with a reason can be inserted here.'),
'payment_info': _('Please transfer money to this bank account: 9999-9999-9999-9999') 'payment_info': _('Please transfer money to this bank account: 9999-9999-9999-9999')
} }

View File

@@ -44,8 +44,8 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import LockTimeoutException from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, render_mail from pretix.base.services.mail import SendMailException, render_mail
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, cancel_order, extend_order, OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded,
) )
from pretix.base.services.stats import order_overview from pretix.base.services.stats import order_overview
from pretix.base.signals import register_data_exporters from pretix.base.signals import register_data_exporters
@@ -228,6 +228,46 @@ class OrderComment(OrderView):
return HttpResponseNotAllowed(['POST']) return HttpResponseNotAllowed(['POST'])
class OrderApprove(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
if self.order.require_approval:
try:
approve_order(self.order, user=self.request.user)
except OrderError as e:
messages.error(self.request, str(e))
else:
messages.success(self.request, _('The order has been approved.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/approve.html', {
'order': self.order,
})
class OrderDeny(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
if self.order.require_approval:
try:
deny_order(self.order, user=self.request.user,
comment=self.request.POST.get('comment'),
send_mail=self.request.POST.get('send_email') == 'on')
except OrderError as e:
messages.error(self.request, str(e))
else:
messages.success(self.request, _('The order has been denied and is therefore now canceled.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/deny.html', {
'order': self.order,
})
class OrderPaymentCancel(OrderView): class OrderPaymentCancel(OrderView):
permission = 'can_change_orders' permission = 'can_change_orders'

View File

@@ -503,6 +503,10 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def is_applicable(self, request): def is_applicable(self, request):
self.request = request self.request = request
for cartpos in get_cart(self.request):
if cartpos.item.require_approval:
return False
for p in self.request.event.get_payment_providers().values(): for p in self.request.event.get_payment_providers().values():
if p.is_implicit: if p.is_implicit:
if self._is_allowed(p, request): if self._is_allowed(p, request):
@@ -530,8 +534,10 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart(answers=True) ctx['cart'] = self.get_cart(answers=True)
ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request) if self.payment_provider:
ctx['payment_provider'] = self.payment_provider ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment_provider
ctx['require_approval'] = any(cp.item.require_approval for cp in ctx['cart']['positions'])
ctx['addr'] = self.invoice_address ctx['addr'] = self.invoice_address
ctx['confirm_messages'] = self.confirm_messages ctx['confirm_messages'] = self.confirm_messages
ctx['cart_session'] = self.cart_session ctx['cart_session'] = self.cart_session
@@ -566,6 +572,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
if 'payment' not in self.cart_session:
return None
return self.request.event.get_payment_providers().get(self.cart_session['payment']) return self.request.event.get_payment_providers().get(self.cart_session['payment'])
def get(self, request): def get(self, request):
@@ -599,7 +607,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
for receiver, response in order_meta_from_request.send(sender=request.event, request=request): for receiver, response in order_meta_from_request.send(sender=request.event, request=request):
meta_info.update(response) meta_info.update(response)
return self.do(self.request.event.id, self.payment_provider.identifier, 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'), [p.id for p in self.positions], self.cart_session.get('email'),
translation.get_language(), self.invoice_address.pk, meta_info) translation.get_language(), self.invoice_address.pk, meta_info)
@@ -620,10 +628,16 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
return self.get_step_url(self.request) return self.get_step_url(self.request)
def get_order_url(self, order): def get_order_url(self, order):
payment = order.payments.first()
if not payment:
return eventreverse(self.request.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret,
}) + '?thanks=1'
return eventreverse(self.request.event, 'presale:event.order.pay.complete', kwargs={ return eventreverse(self.request.event, 'presale:event.order.pay.complete', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
'payment': order.payments.first().pk 'payment': payment.pk
}) })

View File

@@ -49,24 +49,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-primary"> {% if payment_provider %}
<div class="panel-heading"> <div class="panel panel-primary">
{% if payment_provider.identifier != "free" %} <div class="panel-heading">
<div class="pull-right"> {% if payment_provider.identifier != "free" %}
<a href="{% eventurl request.event "presale:event.checkout" step="payment" cart_namespace=cart_namespace|default_if_none:"" %}"> <div class="pull-right">
<span class="fa fa-edit"></span> <a href="{% eventurl request.event "presale:event.checkout" step="payment" cart_namespace=cart_namespace|default_if_none:"" %}">
{% trans "Modify" %} <span class="fa fa-edit"></span>
</a> {% trans "Modify" %}
</div> </a>
{% endif %} </div>
<h3 class="panel-title"> {% endif %}
{% trans "Payment" %} <h3 class="panel-title">
</h3> {% trans "Payment" %}
</h3>
</div>
<div class="panel-body">
{{ payment }}
</div>
</div> </div>
<div class="panel-body"> {% endif %}
{{ payment }}
</div>
</div>
{% eventsignal event "pretix.presale.signals.checkout_confirm_page_content" request=request %} {% eventsignal event "pretix.presale.signals.checkout_confirm_page_content" request=request %}
<div class="row"> <div class="row">
{% if request.event.settings.invoice_address_asked %} {% if request.event.settings.invoice_address_asked %}
@@ -155,6 +157,17 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if require_approval %}
<div class="alert alert-warning alert-primary">
<strong>
{% trans "Your order requires approval by the event organizer before it can be confirmed and forms a valid contract." %}
</strong>
{% 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 %}
</div>
{% endif %}
<div class="row checkout-button-row clearfix"> <div class="row checkout-button-row clearfix">
<div class="col-md-4"> <div class="col-md-4">
<a class="btn btn-block btn-default btn-lg" <a class="btn btn-block btn-default btn-lg"

View File

@@ -1,7 +1,11 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% if order.status == "n" %} {% if order.status == "n" %}
<span class="label label-warning {{ class }}">{% trans "Payment pending" %}</span> {% if order.require_approval %}
<span class="label label-warning {{ class }}">{% trans "Approval pending" %}</span>
{% else %}
<span class="label label-warning {{ class }}">{% trans "Payment pending" %}</span>
{% endif %}
{% elif order.status == "p" %} {% elif order.status == "p" %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span> <span class="label label-success {{ class }}">{% trans "Paid" %}</span>
{% elif order.status == "e" %} {% elif order.status == "e" %}

View File

@@ -14,9 +14,15 @@
{% if order.status != 'p' %} {% if order.status != 'p' %}
<p> <p>
{% trans "Your order has been placed successfully. See below for details." %}<br> {% trans "Your order has been placed successfully. See below for details." %}<br>
<strong> {% if order.require_approval %}
{% trans "Please note that we still await your payment to complete the process." %} <strong>
</strong> {% trans "Please note that we still await approval by the event organizer before you can pay and complete this order." %}
</strong>
{% else %}
<strong>
{% trans "Please note that we still await your payment to complete the process." %}
</strong>
{% endif %}
</p> </p>
{% elif order.total == 0 %} {% elif order.total == 0 %}
<p>{% trans "Your order has been processed successfully! See below for details." %}</p> <p>{% trans "Your order has been processed successfully! See below for details." %}</p>
@@ -41,7 +47,7 @@
{% include "pretixpresale/event/fragment_order_status.html" with order=order class="pull-right" %} {% include "pretixpresale/event/fragment_order_status.html" with order=order class="pull-right" %}
<div class="clearfix"></div> <div class="clearfix"></div>
</h2> </h2>
{% if order.status == "n" %} {% if order.status == "n" and not order.require_approval %}
<div class="panel panel-danger"> <div class="panel panel-danger">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">

View File

@@ -147,7 +147,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed: if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
ctx['can_pay'] = False 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: elif self.order.status == Order.STATUS_PAID:
ctx['can_pay'] = False 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.')) 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 self.payment.state != OrderPayment.PAYMENT_STATE_CREATED 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.')) messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
@@ -229,7 +230,7 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView):
self.request = request self.request = request
if not self.order: if not self.order:
raise Http404(_('Unknown order code or not authorized to access this 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.')) messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
if (not self.payment.payment_provider.payment_is_valid_session(request) or if (not self.payment.payment_provider.payment_is_valid_session(request) or
@@ -286,7 +287,7 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
self.request = request self.request = request
if not self.order: if not self.order:
raise Http404(_('Unknown order code or not authorized to access this 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.')) messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
if (not self.payment.payment_provider.payment_is_valid_session(request) or if (not self.payment.payment_provider.payment_is_valid_session(request) or
@@ -329,7 +330,7 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
self.request = request self.request = request
if not self.order: if not self.order:
raise Http404(_('Unknown order code or not authorized to access this 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.')) messages.error(request, _('The payment method for this order cannot be changed.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())

View File

@@ -137,6 +137,12 @@
font-size: 22px; font-size: 22px;
padding-top: 14px; padding-top: 14px;
} }
.alert-primary::before {
background: $brand-primary !important;
}
.alert-primary {
border-color: $brand-primary !important;
}
.progress-bar { .progress-bar {
box-shadow: none; box-shadow: none;

View File

@@ -230,6 +230,7 @@ TEST_ITEM_RES = {
"max_per_order": None, "max_per_order": None,
"checkin_attention": False, "checkin_attention": False,
"has_variations": False, "has_variations": False,
"require_approval": False,
"variations": [], "variations": [],
"addons": [], "addons": [],
"original_price": None "original_price": None

View File

@@ -203,6 +203,7 @@ TEST_ORDER_RES = {
"vat_id": "", "vat_id": "",
"vat_id_validated": False "vat_id_validated": False
}, },
"require_approval": False,
"positions": [TEST_ORDERPOSITION_RES], "positions": [TEST_ORDERPOSITION_RES],
"downloads": [], "downloads": [],
"payments": TEST_PAYMENTS_RES, "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" 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 = { ORDER_CREATE_PAYLOAD = {
"email": "dummy@dummy.test", "email": "dummy@dummy.test",
"locale": "en", "locale": "en",

View File

@@ -73,6 +73,8 @@ event_permission_sub_urls = [
('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), ('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_expired/', 404),
('post', 'can_change_orders', 'orders/ABC12/mark_canceled/', 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), ('post', 'can_change_orders', 'orders/ABC12/extend/', 400),
('get', 'can_view_orders', 'orders/ABC12/payments/', 404), ('get', 'can_view_orders', 'orders/ABC12/payments/', 404),
('get', 'can_view_orders', 'orders/ABC12/payments/1/', 404), ('get', 'can_view_orders', 'orders/ABC12/payments/1/', 404),

View File

@@ -18,8 +18,8 @@ from pretix.base.payment import FreeOrderProvider
from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services.invoices import generate_invoice from pretix.base.services.invoices import generate_invoice
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, expire_orders, OrderChangeManager, OrderError, _create_order, approve_order, deny_order,
send_download_reminders, expire_orders, send_download_reminders,
) )
@@ -207,6 +207,88 @@ def test_expiring_auto_disabled(event):
assert o2.status == Order.STATUS_PENDING 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): class DownloadReminderTests(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View File

@@ -92,6 +92,13 @@ def test_order_list(client, env):
response = client.get('/control/event/dummy/dummy/orders/?status=o') response = client.get('/control/event/dummy/dummy/orders/?status=o')
assert 'FOO' in response.rendered_content 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 = Question.objects.create(event=env[0], question="Q", type="N", required=True)
q.items.add(env[3]) q.items.add(env[3])
op = env[2].positions.first() 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 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.django_db
@pytest.mark.parametrize("process", [ @pytest.mark.parametrize("process", [
# (Old status, new status, success expected) # (Old status, new status, success expected)

View File

@@ -102,6 +102,8 @@ event_urls = [
"orders/ABC/contact", "orders/ABC/contact",
"orders/ABC/comment", "orders/ABC/comment",
"orders/ABC/locale", "orders/ABC/locale",
"orders/ABC/approve",
"orders/ABC/deny",
"orders/ABC/checkvatid", "orders/ABC/checkvatid",
"orders/ABC/payments/1/cancel", "orders/ABC/payments/1/cancel",
"orders/ABC/payments/1/confirm", "orders/ABC/payments/1/confirm",
@@ -257,6 +259,8 @@ event_permission_urls = [
("can_change_orders", "orders/FOO/resend", 405), ("can_change_orders", "orders/FOO/resend", 405),
("can_change_orders", "orders/FOO/invoice", 405), ("can_change_orders", "orders/FOO/invoice", 405),
("can_change_orders", "orders/FOO/change", 200), ("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/comment", 405),
("can_change_orders", "orders/FOO/locale", 200), ("can_change_orders", "orders/FOO/locale", 200),
("can_view_orders", "orders/FOO/answer/5/", 404), ("can_view_orders", "orders/FOO/answer/5/", 404),

View File

@@ -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}/comment', 405),
('/control/event/{orga}/{event}/orders/{order_code}/change', 200), ('/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}/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}/cancel', 200),
('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/confirm', 200), ('/control/event/{orga}/{event}/orders/{order_code}/payments/{payment}/confirm', 200),
('/control/event/{orga}/{event}/orders/{order_code}/refund', 200), ('/control/event/{orga}/{event}/orders/{order_code}/refund', 200),

View File

@@ -14,7 +14,7 @@ from django_countries.fields import Country
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemCategory, Order, CartPosition, Event, Invoice, InvoiceAddress, Item, ItemCategory, Order,
OrderPosition, Organizer, Question, QuestionAnswer, Quota, Voucher, OrderPosition, Organizer, Question, QuestionAnswer, Quota, Voucher,
) )
from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem 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.count(), 1)
self.assertEqual(OrderPosition.objects.first().subevent, se) 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): def test_free_price(self):
self.ticket.free_price = True self.ticket.free_price = True
self.ticket.save() self.ticket.save()