forked from CGM_Public/pretix_original
3183 lines
146 KiB
Python
3183 lines
146 KiB
Python
#
|
|
# This file is part of pretix (Community Edition).
|
|
#
|
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
#
|
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
# this file, see <https://pretix.eu/about/en/license>.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
# <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
|
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
|
#
|
|
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
|
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
|
#
|
|
# This file contains Apache-licensed contributions copyrighted by: Daniel, Flavia Bastos, Heok Hong Low, Jakob Schnell,
|
|
# Sanket Dasgupta, Tobias Kunze
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations under the License.
|
|
|
|
import json
|
|
import logging
|
|
import operator
|
|
import sys
|
|
from collections import Counter, defaultdict, namedtuple
|
|
from datetime import datetime, time, timedelta
|
|
from decimal import Decimal
|
|
from functools import reduce
|
|
from time import sleep
|
|
from typing import List, Optional
|
|
|
|
from celery.exceptions import MaxRetriesExceededError
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from django.db.models import (
|
|
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
|
|
Value,
|
|
)
|
|
from django.db.models.functions import Coalesce, Greatest
|
|
from django.db.transaction import get_connection
|
|
from django.dispatch import receiver
|
|
from django.utils.functional import cached_property
|
|
from django.utils.timezone import make_aware, now
|
|
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
|
from django_scopes import scopes_disabled
|
|
|
|
from pretix.api.models import OAuthApplication
|
|
from pretix.base.email import get_email_context
|
|
from pretix.base.i18n import get_language_without_region, language
|
|
from pretix.base.media import MEDIA_TYPES
|
|
from pretix.base.models import (
|
|
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
|
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
|
Voucher,
|
|
)
|
|
from pretix.base.models.event import SubEvent
|
|
from pretix.base.models.orders import (
|
|
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
|
generate_secret,
|
|
)
|
|
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
|
|
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
|
from pretix.base.payment import GiftCardPayment, PaymentException
|
|
from pretix.base.reldate import RelativeDateWrapper
|
|
from pretix.base.secrets import assign_ticket_secret
|
|
from pretix.base.services import tickets
|
|
from pretix.base.services.invoices import (
|
|
generate_cancellation, generate_invoice, invoice_qualified,
|
|
)
|
|
from pretix.base.services.locking import (
|
|
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
|
|
)
|
|
from pretix.base.services.mail import SendMailException
|
|
from pretix.base.services.memberships import (
|
|
create_membership, validate_memberships_in_order,
|
|
)
|
|
from pretix.base.services.pricing import (
|
|
apply_discounts, get_listed_price, get_price,
|
|
)
|
|
from pretix.base.services.quotas import QuotaAvailability
|
|
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
|
from pretix.base.signals import (
|
|
order_approved, order_canceled, order_changed, order_denied, order_expired,
|
|
order_fee_calculation, order_paid, order_placed, order_reactivated,
|
|
order_split, order_valid_if_pending, periodic_task, validate_order,
|
|
)
|
|
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
|
from pretix.celery_app import app
|
|
from pretix.helpers import OF_SELF
|
|
from pretix.helpers.models import modelcopy
|
|
from pretix.helpers.periodic import minimum_interval
|
|
from pretix.testutils.middleware import debugflags_var
|
|
|
|
|
|
class OrderError(Exception):
|
|
def __init__(self, *args):
|
|
msg = args[0]
|
|
msgargs = args[1] if len(args) > 1 else None
|
|
self.args = args
|
|
if msgargs:
|
|
msg = _(msg) % msgargs
|
|
else:
|
|
# force msg to string to make sure lazy-translation is done in current locale-context
|
|
# otherwise translation might happen in celery-context, which uses default-locale
|
|
# also translate with _/gettext to keep it backwards compatible
|
|
msg = _(str(msg))
|
|
super().__init__(msg)
|
|
|
|
|
|
error_messages = {
|
|
'unavailable': gettext_lazy(
|
|
'Some of the products you selected were no longer available. '
|
|
'Please see below for details.'
|
|
),
|
|
'in_part': gettext_lazy(
|
|
'Some of the products you selected were no longer available in '
|
|
'the quantity you selected. Please see below for details.'
|
|
),
|
|
'price_changed': gettext_lazy(
|
|
'The price of some of the items in your cart has changed in the '
|
|
'meantime. Please see below for details.'
|
|
),
|
|
'internal': gettext_lazy("An internal error occurred, please try again."),
|
|
'race_condition': gettext_lazy("This order was changed by someone else simultaneously. Please check if your "
|
|
"changes are still accurate and try again."),
|
|
'empty': gettext_lazy("Your cart is empty."),
|
|
'max_items_per_product': ngettext_lazy(
|
|
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
|
|
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
|
|
"max"
|
|
),
|
|
'busy': gettext_lazy(
|
|
'We were not able to process your request completely as the '
|
|
'server was too busy. Please try again.'
|
|
),
|
|
'not_started': gettext_lazy('The booking period for this event has not yet started.'),
|
|
'ended': gettext_lazy('The booking period has ended.'),
|
|
'voucher_min_usages': ngettext_lazy(
|
|
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
|
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
|
'number'
|
|
),
|
|
'voucher_invalid': gettext_lazy('The voucher code used for one of the items in your cart is not known in our database.'),
|
|
'voucher_redeemed': gettext_lazy(
|
|
'The voucher code used for one of the items in your cart has already been used the maximum '
|
|
'number of times allowed. We removed this item from your cart.'
|
|
),
|
|
'voucher_budget_used': gettext_lazy(
|
|
'The voucher code used for one of the items in your cart has already been too often. We '
|
|
'adjusted the price of the item in your cart.'
|
|
),
|
|
'voucher_expired': gettext_lazy(
|
|
'The voucher code used for one of the items in your cart is expired. We removed this item from your cart.'
|
|
),
|
|
'voucher_invalid_item': gettext_lazy(
|
|
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
|
|
),
|
|
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
|
|
'some_subevent_not_started': gettext_lazy(
|
|
'The booking period for one of the events in your cart has not yet started. The '
|
|
'affected positions have been removed from your cart.'
|
|
),
|
|
'some_subevent_ended': gettext_lazy(
|
|
'The booking period for one of the events in your cart has ended. The affected '
|
|
'positions have been removed from your cart.'
|
|
),
|
|
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
|
|
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
|
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
|
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
|
'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'),
|
|
'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'),
|
|
'addon_max_count': ngettext_lazy(
|
|
'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.',
|
|
'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.',
|
|
'max'
|
|
),
|
|
'addon_min_count': ngettext_lazy(
|
|
'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.',
|
|
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
|
'min'
|
|
),
|
|
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
|
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
|
|
'currency_XXX': gettext_lazy('Paid products not supported without a valid currency.'),
|
|
}
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def mark_order_paid(*args, **kwargs):
|
|
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
|
|
|
|
|
|
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None):
|
|
"""
|
|
Reactivates a canceled order. If ``force`` is not set to ``True``, this will fail if there is not
|
|
enough quota.
|
|
"""
|
|
if order.status != Order.STATUS_CANCELED:
|
|
raise OrderError(_('The order was not canceled.'))
|
|
|
|
with transaction.atomic():
|
|
is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
|
|
check_memberships=True, lock=True, force=force)
|
|
if is_available is True:
|
|
if order.payment_refund_sum >= order.total and not order.require_approval:
|
|
order.status = Order.STATUS_PAID
|
|
else:
|
|
order.status = Order.STATUS_PENDING
|
|
order.cancellation_date = None
|
|
order.set_expires(now(),
|
|
order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
|
|
order.save(update_fields=['expires', 'status', 'cancellation_date'])
|
|
order.log_action(
|
|
'pretix.event.order.reactivated',
|
|
user=user,
|
|
auth=auth,
|
|
data={
|
|
'expires': order.expires,
|
|
}
|
|
)
|
|
for position in order.positions.all():
|
|
if position.voucher:
|
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
|
|
|
|
for gc in position.issued_gift_cards.all():
|
|
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
|
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
|
|
break
|
|
|
|
for m in position.granted_memberships.all():
|
|
m.canceled = False
|
|
m.save()
|
|
order.create_transactions()
|
|
else:
|
|
raise OrderError(is_available)
|
|
|
|
order_reactivated.send(order.event, order=order)
|
|
if order.status == Order.STATUS_PAID:
|
|
order_paid.send(order.event, order=order)
|
|
|
|
num_invoices = order.invoices.filter(is_cancellation=False).count()
|
|
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
|
|
generate_invoice(order)
|
|
|
|
|
|
def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_pending: bool=None, user: User=None, auth=None):
|
|
"""
|
|
Extends the deadline of an order. If the order is already expired, the quota will be checked to
|
|
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
|
|
will be ignored.
|
|
"""
|
|
if new_date < now():
|
|
raise OrderError(_('The new expiry date needs to be in the future.'))
|
|
|
|
def change(was_expired=True):
|
|
old_date = order.expires
|
|
order.expires = new_date
|
|
if was_expired:
|
|
order.status = Order.STATUS_PENDING
|
|
if valid_if_pending is not None and valid_if_pending != order.valid_if_pending:
|
|
order.valid_if_pending = valid_if_pending
|
|
if valid_if_pending:
|
|
order.log_action(
|
|
'pretix.event.order.valid_if_pending.set',
|
|
user=user,
|
|
auth=auth,
|
|
)
|
|
else:
|
|
order.log_action(
|
|
'pretix.event.order.valid_if_pending.unset',
|
|
user=user,
|
|
auth=auth,
|
|
)
|
|
order.save(update_fields=['valid_if_pending', 'expires'] + (['status'] if was_expired else []))
|
|
if old_date != new_date:
|
|
order.log_action(
|
|
'pretix.event.order.expirychanged',
|
|
user=user,
|
|
auth=auth,
|
|
data={
|
|
'expires': order.expires,
|
|
'force': force,
|
|
'state_change': was_expired
|
|
}
|
|
)
|
|
|
|
if was_expired:
|
|
num_invoices = order.invoices.filter(is_cancellation=False).count()
|
|
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
|
|
generate_invoice(order)
|
|
order.create_transactions()
|
|
|
|
with transaction.atomic():
|
|
if order.status == Order.STATUS_PENDING:
|
|
change(was_expired=False)
|
|
else:
|
|
is_available = order._is_still_available(now(), count_waitinglist=False, lock=True, force=force)
|
|
if is_available is True:
|
|
change(was_expired=True)
|
|
else:
|
|
raise OrderError(is_available)
|
|
|
|
|
|
@transaction.atomic
|
|
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
|
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
|
|
device = auth.pk if isinstance(auth, Device) else None
|
|
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
|
|
return _cancel_order(
|
|
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
|
|
)
|
|
|
|
|
|
def mark_order_expired(order, user=None, auth=None):
|
|
"""
|
|
Mark this order as expired. This sets the payment status and returns the order object.
|
|
:param order: The order to change
|
|
:param user: The user that performed the change
|
|
"""
|
|
with transaction.atomic():
|
|
if isinstance(order, int):
|
|
order = Order.objects.get(pk=order)
|
|
if isinstance(user, int):
|
|
user = User.objects.get(pk=user)
|
|
order.status = Order.STATUS_EXPIRED
|
|
order.save(update_fields=['status'])
|
|
|
|
order.log_action('pretix.event.order.expired', user=user, auth=auth)
|
|
i = order.invoices.filter(is_cancellation=False).last()
|
|
if i and not i.refered.exists():
|
|
generate_cancellation(i)
|
|
order.create_transactions()
|
|
|
|
order_expired.send(order.event, order=order)
|
|
return order
|
|
|
|
|
|
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False):
|
|
"""
|
|
Mark this order as approved
|
|
:param order: The order to change
|
|
:param user: The user that performed the change
|
|
"""
|
|
with transaction.atomic():
|
|
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(update_fields=['require_approval', 'expires'])
|
|
order.create_transactions()
|
|
|
|
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, ignore_date=True, force=force)
|
|
except Quota.QuotaExceededException:
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
order_approved.send(order.event, order=order)
|
|
|
|
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:
|
|
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:
|
|
with language(order.locale, order.event.settings.region):
|
|
if order.total == Decimal('0.00'):
|
|
email_template = order.event.settings.mail_text_order_approved_free
|
|
email_subject = order.event.settings.mail_subject_order_approved_free
|
|
email_attendees = order.event.settings.mail_send_order_approved_free_attendee
|
|
email_attendee_template = order.event.settings.mail_text_order_approved_free_attendee
|
|
email_attendee_subject = order.event.settings.mail_subject_order_approved_free_attendee
|
|
else:
|
|
email_template = order.event.settings.mail_text_order_approved
|
|
email_subject = order.event.settings.mail_subject_order_approved
|
|
email_attendees = order.event.settings.mail_send_order_approved_attendee
|
|
email_attendee_template = order.event.settings.mail_text_order_approved_attendee
|
|
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
|
|
|
|
email_context = get_email_context(event=order.event, order=order)
|
|
try:
|
|
order.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.order_approved', user,
|
|
attach_tickets=True,
|
|
attach_ical=order.event.settings.mail_attach_ical and (
|
|
not order.event.settings.mail_attach_ical_paid_only or
|
|
order.total == Decimal('0.00') or
|
|
order.valid_if_pending
|
|
),
|
|
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order approved email could not be sent')
|
|
|
|
if email_attendees:
|
|
for p in order.positions.all():
|
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
|
email_attendee_context = get_email_context(event=order.event, order=order, position=p)
|
|
try:
|
|
p.send_mail(
|
|
email_attendee_subject, email_attendee_template, email_attendee_context,
|
|
'pretix.event.order.email.order_approved', user,
|
|
attach_tickets=True,
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order approved email could not be sent to attendee')
|
|
|
|
return order.pk
|
|
|
|
|
|
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
|
|
"""
|
|
with transaction.atomic():
|
|
if not order.require_approval or not order.status == Order.STATUS_PENDING:
|
|
raise OrderError(_('This order is not pending approval.'))
|
|
|
|
order.status = Order.STATUS_CANCELED
|
|
order.save(update_fields=['status'])
|
|
|
|
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=Greatest(0, F('redeemed') - 1))
|
|
order.create_transactions()
|
|
|
|
order_denied.send(order.event, order=order)
|
|
|
|
if send_mail:
|
|
with language(order.locale, order.event.settings.region):
|
|
email_template = order.event.settings.mail_text_order_denied
|
|
email_subject = order.event.settings.mail_subject_order_denied
|
|
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
|
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
|
|
|
|
|
|
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
|
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
|
|
"""
|
|
Mark this order as canceled
|
|
:param order: The order to change
|
|
:param user: The user that performed the change
|
|
"""
|
|
# If new actions are added to this function, make sure to add the reverse operation to reactivate_order()
|
|
with transaction.atomic():
|
|
if isinstance(order, int):
|
|
order = Order.objects.select_for_update(of=OF_SELF).get(pk=order)
|
|
if isinstance(user, int):
|
|
user = User.objects.get(pk=user)
|
|
if isinstance(api_token, int):
|
|
api_token = TeamAPIToken.objects.get(pk=api_token)
|
|
if isinstance(device, int):
|
|
device = Device.objects.get(pk=device)
|
|
if isinstance(oauth_application, int):
|
|
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
|
if isinstance(cancellation_fee, str):
|
|
cancellation_fee = Decimal(cancellation_fee)
|
|
|
|
if not order.cancel_allowed():
|
|
raise OrderError(_('You cannot cancel this order.'))
|
|
invoices = []
|
|
if cancel_invoice:
|
|
i = order.invoices.filter(is_cancellation=False).last()
|
|
if i and not i.refered.exists():
|
|
invoices.append(generate_cancellation(i))
|
|
|
|
for position in order.positions.all():
|
|
for gc in position.issued_gift_cards.all():
|
|
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
|
if gc.value < position.price:
|
|
raise OrderError(
|
|
_('This order can not be canceled since the gift card {card} purchased in '
|
|
'this order has already been redeemed.').format(
|
|
card=gc.secret
|
|
)
|
|
)
|
|
else:
|
|
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
|
|
|
|
for m in position.granted_memberships.all():
|
|
m.canceled = True
|
|
m.save()
|
|
|
|
if cancellation_fee:
|
|
for position in order.positions.all():
|
|
if position.voucher:
|
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
|
position.canceled = True
|
|
assign_ticket_secret(
|
|
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
|
)
|
|
position.save(update_fields=['canceled', 'secret'])
|
|
new_fee = cancellation_fee
|
|
for fee in order.fees.all():
|
|
if keep_fees and fee in keep_fees:
|
|
new_fee -= fee.value
|
|
else:
|
|
fee.canceled = True
|
|
fee.save(update_fields=['canceled'])
|
|
|
|
if new_fee:
|
|
f = OrderFee(
|
|
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
|
value=new_fee,
|
|
tax_rule=order.event.settings.tax_rate_default,
|
|
order=order,
|
|
)
|
|
f._calculate_tax()
|
|
f.save()
|
|
|
|
if cancellation_fee > order.total:
|
|
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
|
|
elif order.payment_refund_sum < cancellation_fee:
|
|
order.status = Order.STATUS_PENDING
|
|
order.set_expires()
|
|
else:
|
|
order.status = Order.STATUS_PAID
|
|
order.total = cancellation_fee
|
|
order.cancellation_date = now()
|
|
order.save(update_fields=['status', 'cancellation_date', 'total'])
|
|
|
|
if cancel_invoice and i:
|
|
invoices.append(generate_invoice(order))
|
|
else:
|
|
order.status = Order.STATUS_CANCELED
|
|
order.cancellation_date = now()
|
|
order.save(update_fields=['status', 'cancellation_date'])
|
|
|
|
for position in order.positions.all():
|
|
assign_ticket_secret(
|
|
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=True
|
|
)
|
|
if position.voucher:
|
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
|
|
|
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
|
data={'cancellation_fee': cancellation_fee, 'comment': comment})
|
|
order.cancellation_requests.all().delete()
|
|
|
|
order.create_transactions()
|
|
|
|
if send_mail:
|
|
with language(order.locale, order.event.settings.region):
|
|
email_template = order.event.settings.mail_text_order_canceled
|
|
email_subject = order.event.settings.mail_subject_order_canceled
|
|
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
|
|
try:
|
|
order.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.order_canceled', user,
|
|
invoices=invoices if order.event.settings.invoice_email_attachment else []
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order canceled email could not be sent')
|
|
|
|
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
|
|
try:
|
|
with transaction.atomic():
|
|
p.payment_provider.cancel_payment(p)
|
|
order.log_action(
|
|
'pretix.event.order.payment.canceled',
|
|
{
|
|
'local_id': p.local_id,
|
|
'provider': p.provider,
|
|
},
|
|
user=user,
|
|
auth=api_token or oauth_application or device
|
|
)
|
|
except PaymentException as e:
|
|
order.log_action(
|
|
'pretix.event.order.payment.canceled.failed',
|
|
{
|
|
'local_id': p.local_id,
|
|
'provider': p.provider,
|
|
'error': str(e)
|
|
},
|
|
user=user,
|
|
auth=api_token or oauth_application or device
|
|
)
|
|
|
|
order_canceled.send(order.event, order=order)
|
|
return order.pk
|
|
|
|
|
|
def _check_date(event: Event, now_dt: datetime):
|
|
if event.presale_start and now_dt < event.presale_start:
|
|
raise OrderError(error_messages['not_started'])
|
|
if event.presale_has_ended:
|
|
raise OrderError(error_messages['ended'])
|
|
|
|
if not event.has_subevents:
|
|
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
|
if tlv:
|
|
term_last = make_aware(datetime.combine(
|
|
tlv.datetime(event).date(),
|
|
time(hour=23, minute=59, second=59)
|
|
), event.timezone)
|
|
if term_last < now_dt:
|
|
raise OrderError(error_messages['ended'])
|
|
|
|
|
|
def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition],
|
|
sales_channel: SalesChannel, address: InvoiceAddress=None, customer=None):
|
|
err = None
|
|
_check_date(event, time_machine_now_dt)
|
|
|
|
products_seen = Counter()
|
|
q_avail = Counter()
|
|
v_avail = Counter()
|
|
v_usages = Counter()
|
|
v_budget = {}
|
|
deleted_positions = set()
|
|
seats_seen = set()
|
|
|
|
def delete(cp):
|
|
# Delete a cart position, including parents and children, if applicable
|
|
if cp.is_bundled:
|
|
delete(cp.addon_to)
|
|
else:
|
|
for p in cp.addons.all():
|
|
deleted_positions.add(p.pk)
|
|
p.delete()
|
|
deleted_positions.add(cp.pk)
|
|
cp.delete()
|
|
|
|
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
|
|
|
|
for cp in sorted_positions:
|
|
cp._cached_quotas = list(cp.quotas)
|
|
|
|
# Create locks
|
|
if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions):
|
|
# No need to perform any locking if the cart positions still guarantee everything long enough.
|
|
full_lock_required = any(
|
|
getattr(o, 'seat', False) for o in sorted_positions
|
|
) and event.settings.seating_minimal_distance > 0
|
|
if full_lock_required:
|
|
# We lock the entire event in this case since we don't want to deal with fine-granular locking
|
|
# in the case of seating distance enforcement
|
|
lock_objects([event])
|
|
else:
|
|
lock_objects(
|
|
[q for q in reduce(operator.or_, (set(cp._cached_quotas) for cp in sorted_positions), set()) if q.size is not None] +
|
|
[op.voucher for op in sorted_positions if op.voucher] +
|
|
[op.seat for op in sorted_positions if op.seat],
|
|
shared_lock_objects=[event]
|
|
)
|
|
|
|
# Check availability
|
|
for i, cp in enumerate(sorted_positions):
|
|
if cp.pk in deleted_positions:
|
|
continue
|
|
|
|
if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()):
|
|
err = err or error_messages['unavailable']
|
|
delete(cp)
|
|
continue
|
|
quotas = cp._cached_quotas
|
|
|
|
products_seen[cp.item] += 1
|
|
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
|
err = error_messages['max_items_per_product'] % {
|
|
'max': cp.item.max_per_order,
|
|
'product': cp.item.name
|
|
}
|
|
delete(cp)
|
|
break
|
|
|
|
if cp.voucher:
|
|
v_usages[cp.voucher] += 1
|
|
if cp.voucher not in v_avail:
|
|
cp.voucher.refresh_from_db(fields=['redeemed'])
|
|
redeemed_in_carts = CartPosition.objects.filter(
|
|
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
|
).exclude(cart_id=cp.cart_id)
|
|
v_avail[cp.voucher] = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
|
v_avail[cp.voucher] -= 1
|
|
if v_avail[cp.voucher] < 0:
|
|
err = err or error_messages['voucher_redeemed']
|
|
delete(cp)
|
|
continue
|
|
|
|
if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
|
|
err = err or error_messages['some_subevent_not_started']
|
|
delete(cp)
|
|
break
|
|
|
|
if cp.subevent:
|
|
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
|
if tlv:
|
|
term_last = make_aware(datetime.combine(
|
|
tlv.datetime(cp.subevent).date(),
|
|
time(hour=23, minute=59, second=59)
|
|
), event.timezone)
|
|
if term_last < time_machine_now_dt:
|
|
err = err or error_messages['some_subevent_ended']
|
|
delete(cp)
|
|
break
|
|
|
|
if cp.subevent and cp.subevent.presale_has_ended:
|
|
err = err or error_messages['some_subevent_ended']
|
|
delete(cp)
|
|
break
|
|
|
|
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
|
|
err = err or error_messages['seat_invalid']
|
|
delete(cp)
|
|
break
|
|
if cp.seat:
|
|
seats_seen.add(cp.seat)
|
|
|
|
if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled:
|
|
delete(cp)
|
|
err = err or error_messages['voucher_required']
|
|
break
|
|
|
|
if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and (
|
|
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
|
) and not cp.is_bundled:
|
|
delete(cp)
|
|
err = error_messages['voucher_required']
|
|
break
|
|
|
|
if cp.seat:
|
|
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
|
|
# time, since we absolutely can not overbook a seat.
|
|
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
|
|
err = err or error_messages['seat_unavailable']
|
|
delete(cp)
|
|
continue
|
|
|
|
if cp.expires >= now_dt and not cp.voucher:
|
|
# Other checks are not necessary
|
|
continue
|
|
|
|
if len(quotas) == 0:
|
|
err = err or error_messages['unavailable']
|
|
delete(cp)
|
|
continue
|
|
|
|
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt):
|
|
err = err or error_messages['unavailable']
|
|
delete(cp)
|
|
continue
|
|
|
|
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
|
|
not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt):
|
|
err = err or error_messages['unavailable']
|
|
delete(cp)
|
|
continue
|
|
|
|
if cp.voucher:
|
|
if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt:
|
|
err = err or error_messages['voucher_expired']
|
|
delete(cp)
|
|
continue
|
|
|
|
quota_ok = True
|
|
|
|
ignore_all_quotas = cp.expires >= now_dt or (
|
|
cp.voucher and (
|
|
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
|
|
)
|
|
)
|
|
|
|
if not ignore_all_quotas:
|
|
for quota in quotas:
|
|
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
|
|
continue
|
|
if quota not in q_avail:
|
|
avail = quota.availability(now_dt)
|
|
q_avail[quota] = avail[1] if avail[1] is not None else sys.maxsize
|
|
q_avail[quota] -= 1
|
|
if q_avail[quota] < 0:
|
|
err = err or error_messages['unavailable']
|
|
quota_ok = False
|
|
break
|
|
|
|
if not quota_ok:
|
|
# Sorry, can't let you keep that!
|
|
delete(cp)
|
|
|
|
for voucher, cnt in v_usages.items():
|
|
if 0 < cnt < voucher.min_usages_remaining:
|
|
raise OrderError(error_messages['voucher_min_usages'] % {
|
|
'voucher': voucher.code,
|
|
'number': voucher.min_usages_remaining,
|
|
})
|
|
|
|
# Check prices
|
|
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
|
old_total = sum(cp.price for cp in sorted_positions)
|
|
for i, cp in enumerate(sorted_positions):
|
|
if cp.listed_price is None:
|
|
# migration from pre-discount cart positions
|
|
cp.update_listed_price_and_voucher(max_discount=None)
|
|
cp.migrate_free_price_if_necessary()
|
|
|
|
# deal with max discount
|
|
max_discount = None
|
|
if cp.voucher and cp.voucher.budget is not None:
|
|
if cp.voucher not in v_budget:
|
|
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
|
|
max_discount = max(v_budget[cp.voucher], 0)
|
|
|
|
if cp.expires < now_dt or cp.listed_price is None:
|
|
# Guarantee on listed price is expired
|
|
cp.update_listed_price_and_voucher(max_discount=max_discount)
|
|
elif cp.voucher:
|
|
cp.update_listed_price_and_voucher(max_discount=max_discount, voucher_only=True)
|
|
|
|
if max_discount is not None:
|
|
v_budget[cp.voucher] = v_budget[cp.voucher] - (cp.listed_price - cp.price_after_voucher)
|
|
|
|
try:
|
|
cp.update_line_price(address, [b for b in sorted_positions if b.addon_to_id == cp.pk and b.is_bundled and b.pk and b.pk not in deleted_positions])
|
|
except TaxRule.SaleNotAllowed:
|
|
err = err or error_messages['country_blocked']
|
|
delete(cp)
|
|
continue
|
|
|
|
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
|
discount_results = apply_discounts(
|
|
event,
|
|
sales_channel.identifier,
|
|
[
|
|
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
|
for cp in sorted_positions
|
|
]
|
|
)
|
|
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
|
|
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
|
cp.price = new_price
|
|
cp.discount = discount
|
|
cp.save(update_fields=['price', 'discount'])
|
|
|
|
# After applying discounts, add-on positions might still have a reference to the *old* version of the
|
|
# parent position, which can screw up ordering later since the system sees inconsistent data.
|
|
by_id = {cp.pk: cp for cp in sorted_positions}
|
|
for cp in sorted_positions:
|
|
if cp.addon_to_id:
|
|
cp.addon_to = by_id[cp.addon_to_id]
|
|
|
|
new_total = sum(cp.price for cp in sorted_positions)
|
|
if old_total != new_total:
|
|
err = err or error_messages['price_changed']
|
|
|
|
# Store updated positions
|
|
for cp in sorted_positions:
|
|
cp.expires = now_dt + timedelta(
|
|
minutes=event.settings.get('reservation_time', as_type=int))
|
|
cp.save(update_fields=['expires'])
|
|
|
|
if err:
|
|
raise OrderError(err)
|
|
|
|
|
|
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
|
meta_info: dict, event: Event, require_approval=False):
|
|
fees = []
|
|
total = sum([c.price for c in positions])
|
|
|
|
gift_cards = [] # for backwards compatibility
|
|
for p in payment_requests:
|
|
if p['provider'] == 'giftcard':
|
|
gift_cards.append(GiftCard.objects.get(pk=p['info_data']['gift_card']))
|
|
|
|
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, payment_requests=payment_requests,
|
|
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
|
if resp:
|
|
fees += resp
|
|
total += sum(f.value for f in fees)
|
|
|
|
total_remaining = total
|
|
for p in payment_requests:
|
|
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
|
# places in the code base:
|
|
# - pretix.base.services.cart.get_fees
|
|
# - pretix.base.services.orders._get_fees
|
|
# - pretix.presale.views.CartMixin.current_selected_payments
|
|
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
|
p['payment_amount'] = Decimal('0.00')
|
|
continue
|
|
|
|
to_pay = total_remaining
|
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
|
|
|
payment_fee = p['pprov'].calculate_fee(to_pay)
|
|
total_remaining += payment_fee
|
|
to_pay += payment_fee
|
|
|
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
|
|
|
total_remaining -= to_pay
|
|
|
|
p['payment_amount'] = to_pay
|
|
if payment_fee:
|
|
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
|
internal_type=p['pprov'].identifier)
|
|
fees.append(pf)
|
|
p['fee'] = pf
|
|
|
|
if total_remaining != Decimal('0.00') and not require_approval:
|
|
raise OrderError(_("The selected payment methods do not cover the total balance."))
|
|
|
|
return fees
|
|
|
|
|
|
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
|
|
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
|
|
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
|
|
customer=None, valid_if_pending=False):
|
|
payments = []
|
|
|
|
try:
|
|
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
|
|
except ValidationError as e:
|
|
raise OrderError(e.message)
|
|
|
|
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
|
try:
|
|
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(error_messages['country_blocked'])
|
|
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
|
|
|
order = Order(
|
|
status=Order.STATUS_PENDING,
|
|
event=event,
|
|
email=email,
|
|
phone=(meta_info or {}).get('contact_form_data', {}).get('phone'),
|
|
datetime=now_dt,
|
|
locale=get_language_without_region(locale),
|
|
total=total,
|
|
testmode=True if sales_channel.type_instance.testmode_supported and event.testmode else False,
|
|
meta_info=json.dumps(meta_info or {}),
|
|
require_approval=require_approval,
|
|
sales_channel=sales_channel,
|
|
customer=customer,
|
|
valid_if_pending=valid_if_pending,
|
|
)
|
|
if customer:
|
|
order.email_known_to_work = customer.is_verified
|
|
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
|
order.save()
|
|
|
|
if address:
|
|
if address.order is not None:
|
|
address.pk = None
|
|
address.order = order
|
|
address.save()
|
|
|
|
for fee in fees:
|
|
fee.order = order
|
|
try:
|
|
fee._calculate_tax()
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(error_messages['country_blocked'])
|
|
if fee.tax_rule and not fee.tax_rule.pk:
|
|
fee.tax_rule = None # TODO: deprecate
|
|
fee.save()
|
|
|
|
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
|
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
|
|
# We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions,
|
|
# but this is now a payment error instead. So currently this code branch is usually only triggered by bugs
|
|
# in other places (e.g. tax calculation).
|
|
if shown_total is not None:
|
|
if Decimal(shown_total) != pending_sum:
|
|
raise OrderError(
|
|
_('While trying to place your order, we noticed that the order total has changed. Either one of '
|
|
'the prices changed just now, or a gift card you used has been used in the meantime. Please '
|
|
'check the prices below and try again.')
|
|
)
|
|
|
|
if payment_requests and not order.require_approval:
|
|
for p in payment_requests:
|
|
if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'):
|
|
payments.append(order.payments.create(
|
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
|
provider=p['provider'],
|
|
amount=p['payment_amount'],
|
|
fee=p.get('fee'),
|
|
info=json.dumps(p['info_data']),
|
|
process_initiated=False,
|
|
))
|
|
|
|
orderpositions = OrderPosition.transform_cart_positions(positions, order)
|
|
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
|
|
order.log_action('pretix.event.order.placed')
|
|
if order.require_approval:
|
|
order.log_action('pretix.event.order.placed.require_approval')
|
|
if meta_info:
|
|
for msg in meta_info.get('confirm_messages', []):
|
|
order.log_action('pretix.event.order.consent', data={'msg': msg})
|
|
|
|
order_placed.send(event, order=order)
|
|
return order, payments
|
|
|
|
|
|
def _order_placed_email(event: Event, order: Order, email_template, subject_template,
|
|
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
|
|
email_context = get_email_context(event=event, order=order, payments=payments)
|
|
try:
|
|
order.send_mail(
|
|
subject_template, email_template, email_context,
|
|
log_entry,
|
|
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
|
attach_tickets=True,
|
|
attach_ical=event.settings.mail_attach_ical and (
|
|
not event.settings.mail_attach_ical_paid_only or
|
|
is_free or
|
|
order.valid_if_pending
|
|
),
|
|
attach_other_files=[a for a in [
|
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
|
] if a],
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order received email could not be sent')
|
|
|
|
|
|
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
|
|
log_entry: str, is_free=False):
|
|
email_context = get_email_context(event=event, order=order, position=position)
|
|
|
|
try:
|
|
position.send_mail(
|
|
subject_template, email_template, email_context,
|
|
log_entry,
|
|
invoices=[],
|
|
attach_tickets=True,
|
|
attach_ical=event.settings.mail_attach_ical and (
|
|
not event.settings.mail_attach_ical_paid_only or
|
|
is_free or
|
|
order.valid_if_pending
|
|
),
|
|
attach_other_files=[a for a in [
|
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
|
] if a],
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order received email could not be sent to attendee')
|
|
|
|
|
|
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
|
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
|
shown_total=None, customer=None):
|
|
for p in payment_requests:
|
|
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
|
|
if not p['pprov']:
|
|
raise OrderError(error_messages['internal'])
|
|
|
|
if customer:
|
|
customer = event.organizer.customers.get(pk=customer)
|
|
|
|
try:
|
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
|
except SalesChannel.DoesNotExist:
|
|
raise OrderError("Invalid sales channel.")
|
|
|
|
if email == settings.PRETIX_EMAIL_NONE_VALUE:
|
|
email = None
|
|
|
|
addr = None
|
|
if address is not None:
|
|
try:
|
|
with scopes_disabled():
|
|
addr = InvoiceAddress.objects.get(pk=address)
|
|
except InvoiceAddress.DoesNotExist:
|
|
pass
|
|
|
|
requires_seat = Exists(
|
|
SeatCategoryMapping.objects.filter(
|
|
Q(product=OuterRef('item'))
|
|
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
|
|
)
|
|
)
|
|
if not event.settings.seating_choice:
|
|
requires_seat = Value(0, output_field=IntegerField())
|
|
positions = CartPosition.objects.annotate(
|
|
requires_seat=requires_seat
|
|
).filter(
|
|
id__in=position_ids, event=event
|
|
)
|
|
|
|
if shown_total is not None and Decimal(shown_total) > Decimal("0.00") and event.currency == "XXX":
|
|
raise OrderError(error_messages['currency_XXX'])
|
|
|
|
validate_order.send(
|
|
event,
|
|
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
|
|
payments=payment_requests,
|
|
email=email,
|
|
positions=positions,
|
|
locale=locale,
|
|
invoice_address=addr,
|
|
meta_info=meta_info,
|
|
customer=customer,
|
|
)
|
|
|
|
valid_if_pending = False
|
|
for recv, result in order_valid_if_pending.send(
|
|
event,
|
|
payments=payment_requests,
|
|
email=email,
|
|
positions=positions,
|
|
locale=locale,
|
|
invoice_address=addr,
|
|
meta_info=meta_info,
|
|
customer=customer,
|
|
):
|
|
if result:
|
|
valid_if_pending = True
|
|
|
|
warnings = []
|
|
any_payment_failed = False
|
|
|
|
real_now_dt = now()
|
|
time_machine_now_dt = time_machine_now(real_now_dt)
|
|
err_out = None
|
|
with transaction.atomic(durable=True):
|
|
positions = list(
|
|
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
|
|
)
|
|
positions.sort(key=lambda c: c.sort_key)
|
|
if len(positions) == 0:
|
|
raise OrderError(error_messages['empty'])
|
|
if len(position_ids) != len(positions):
|
|
raise OrderError(error_messages['internal'])
|
|
try:
|
|
_check_positions(event, real_now_dt, time_machine_now_dt, positions,
|
|
address=addr, sales_channel=sales_channel, customer=customer)
|
|
except OrderError as e:
|
|
err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
|
|
else:
|
|
if 'sleep-after-quota-check' in debugflags_var.get():
|
|
sleep(2)
|
|
|
|
order, payment_objs = _create_order(
|
|
event,
|
|
email=email,
|
|
positions=positions,
|
|
now_dt=real_now_dt,
|
|
payment_requests=payment_requests,
|
|
locale=locale,
|
|
address=addr,
|
|
meta_info=meta_info,
|
|
sales_channel=sales_channel,
|
|
shown_total=shown_total,
|
|
customer=customer,
|
|
valid_if_pending=valid_if_pending
|
|
)
|
|
|
|
try:
|
|
for p in payment_objs:
|
|
if p.provider == 'free':
|
|
# Passing lock=False is safe here because it's absolutely impossible for the order to be expired
|
|
# here before it is even committed.
|
|
p.confirm(send_mail=False, lock=False, generate_invoice=False)
|
|
except Quota.QuotaExceededException:
|
|
pass
|
|
if err_out:
|
|
raise err_out
|
|
|
|
# We give special treatment to GiftCardPayment here because our invoice renderer expects gift cards to already be
|
|
# processed, and because we historically treat gift card orders like free orders with regards to email texts.
|
|
# It would be great to give external gift card plugins the same special treatment, but it feels to risky for now, as
|
|
# (a) there would be no email at all if the plugin fails in a weird way and (b) we'd be able to run into
|
|
# contradictions when a plugin set both execute_payment_needs_user=False as well as requires_invoice_immediately=True
|
|
for p in payment_objs:
|
|
if isinstance(p.payment_provider, GiftCardPayment):
|
|
try:
|
|
p.process_initiated = True
|
|
p.save(update_fields=['process_initiated'])
|
|
p.payment_provider.execute_payment(None, p, is_early_special_case=True)
|
|
except PaymentException as e:
|
|
warnings.append(str(e))
|
|
any_payment_failed = True
|
|
except Exception:
|
|
logger.exception('Error during payment attempt')
|
|
else:
|
|
order.refresh_from_db()
|
|
|
|
pending_sum = order.pending_sum
|
|
free_order_flow = (
|
|
payment_objs and
|
|
(
|
|
any(p['provider'] == 'free' for p in payment_requests) or
|
|
all(p['provider'] == 'giftcard' for p in payment_requests)
|
|
) and
|
|
pending_sum == Decimal('0.00') and
|
|
not order.require_approval
|
|
)
|
|
|
|
invoice = order.invoices.last() # Might be generated by plugin already
|
|
if not invoice and invoice_qualified(order):
|
|
invoice_required = (
|
|
event.settings.get('invoice_generate') == 'True' or (
|
|
event.settings.get('invoice_generate') == 'paid' and (
|
|
any(p['pprov'].requires_invoice_immediately for p in payment_requests) or
|
|
pending_sum <= Decimal('0.00')
|
|
)
|
|
)
|
|
)
|
|
if invoice_required:
|
|
invoice = generate_invoice(
|
|
order,
|
|
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
|
|
)
|
|
# send_mail will trigger PDF generation later
|
|
|
|
if order.email:
|
|
if order.require_approval:
|
|
email_template = event.settings.mail_text_order_placed_require_approval
|
|
subject_template = event.settings.mail_subject_order_placed_require_approval
|
|
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
|
|
|
email_attendees = False
|
|
elif free_order_flow:
|
|
email_template = event.settings.mail_text_order_free
|
|
subject_template = event.settings.mail_subject_order_free
|
|
log_entry = 'pretix.event.order.email.order_free'
|
|
|
|
email_attendees = event.settings.mail_send_order_free_attendee
|
|
email_attendees_template = event.settings.mail_text_order_free_attendee
|
|
subject_attendees_template = event.settings.mail_subject_order_free_attendee
|
|
else:
|
|
email_template = event.settings.mail_text_order_placed
|
|
subject_template = event.settings.mail_subject_order_placed
|
|
log_entry = 'pretix.event.order.email.order_placed'
|
|
|
|
email_attendees = event.settings.mail_send_order_placed_attendee
|
|
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
|
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
|
|
|
|
if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid:
|
|
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
|
|
is_free=free_order_flow)
|
|
if email_attendees:
|
|
for p in order.positions.all():
|
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
|
_order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry,
|
|
is_free=free_order_flow)
|
|
|
|
if not any_payment_failed:
|
|
for p in payment_objs:
|
|
if not p.payment_provider.execute_payment_needs_user and not p.process_initiated:
|
|
try:
|
|
p.process_initiated = True
|
|
p.save(update_fields=['process_initiated'])
|
|
resp = p.payment_provider.execute_payment(None, p)
|
|
if isinstance(resp, str):
|
|
logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set')
|
|
except PaymentException as e:
|
|
warnings.append(str(e))
|
|
any_payment_failed = True
|
|
except Exception:
|
|
logger.exception('Error during payment attempt')
|
|
|
|
if any_payment_failed:
|
|
# Cancel all other payments because their amount might be wrong now.
|
|
for p in payment_objs:
|
|
if p.state == OrderPayment.PAYMENT_STATE_CREATED:
|
|
p.state = OrderPayment.PAYMENT_STATE_CANCELED
|
|
p.save(update_fields=['state'])
|
|
|
|
return {
|
|
'order_id': order.id,
|
|
'warnings': warnings,
|
|
}
|
|
|
|
|
|
@receiver(signal=periodic_task)
|
|
@scopes_disabled()
|
|
def expire_orders(sender, **kwargs):
|
|
event_id = None
|
|
expire = None
|
|
|
|
qs = Order.objects.filter(
|
|
expires__lt=now(),
|
|
status=Order.STATUS_PENDING,
|
|
valid_if_pending=False,
|
|
require_approval=False
|
|
).exclude(
|
|
Exists(
|
|
OrderFee.objects.filter(order_id=OuterRef('pk'), fee_type=OrderFee.FEE_TYPE_CANCELLATION)
|
|
)
|
|
).prefetch_related('event').order_by('event_id')
|
|
for o in qs:
|
|
if o.event_id != event_id:
|
|
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
|
event_id = o.event_id
|
|
if expire and now() >= o.payment_term_expire_date:
|
|
mark_order_expired(o)
|
|
|
|
|
|
@receiver(signal=periodic_task)
|
|
@scopes_disabled()
|
|
@minimum_interval(minutes_after_success=60)
|
|
def send_expiry_warnings(sender, **kwargs):
|
|
today = now().replace(hour=0, minute=0, second=0)
|
|
days = None
|
|
settings = None
|
|
event_id = None
|
|
|
|
for o in Order.objects.filter(
|
|
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING,
|
|
datetime__lte=now() - timedelta(hours=2), require_approval=False
|
|
).only('pk', 'event_id', 'expires').order_by('event_id'):
|
|
|
|
lp = o.payments.last()
|
|
if (
|
|
lp and
|
|
lp.state in [OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING] and
|
|
lp.payment_provider and
|
|
lp.payment_provider.prevent_reminder_mail(o, lp)
|
|
):
|
|
continue
|
|
|
|
if event_id != o.event_id:
|
|
settings = o.event.settings
|
|
days = cache.get_or_set('{}:{}:setting_mail_days_order_expire_warning'.format('event', o.event_id),
|
|
default=lambda: settings.get('mail_days_order_expire_warning', as_type=int),
|
|
timeout=3600)
|
|
event_id = o.event_id
|
|
|
|
if days and (o.expires - today).days <= days:
|
|
with transaction.atomic():
|
|
o = Order.objects.select_related('event').select_for_update(of=OF_SELF).get(pk=o.pk)
|
|
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
|
# Race condition
|
|
continue
|
|
|
|
with language(o.locale, settings.region):
|
|
o.expiry_reminder_sent = True
|
|
o.save(update_fields=['expiry_reminder_sent'])
|
|
email_context = get_email_context(event=o.event, order=o)
|
|
can_autoexpire = (
|
|
settings.payment_term_expire_automatically and
|
|
not o.valid_if_pending and
|
|
not o.fees.filter(fee_type=OrderFee.FEE_TYPE_CANCELLATION).exists()
|
|
)
|
|
if can_autoexpire:
|
|
email_template = settings.mail_text_order_expire_warning
|
|
email_subject = settings.mail_subject_order_expire_warning
|
|
else:
|
|
email_template = settings.mail_text_order_pending_warning
|
|
email_subject = settings.mail_subject_order_pending_warning
|
|
|
|
try:
|
|
o.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.expire_warning_sent'
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Reminder email could not be sent')
|
|
|
|
|
|
@receiver(signal=periodic_task)
|
|
@scopes_disabled()
|
|
def send_download_reminders(sender, **kwargs):
|
|
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
qs = Order.objects.annotate(
|
|
first_date=Coalesce(
|
|
Min('all_positions__subevent__date_from'),
|
|
F('event__date_from')
|
|
)
|
|
).filter(
|
|
download_reminder_sent=False,
|
|
datetime__lte=now() - timedelta(hours=2),
|
|
first_date__gte=today,
|
|
).only(
|
|
'pk', 'event_id', 'sales_channel', 'datetime',
|
|
).order_by('event_id')
|
|
event_id = None
|
|
days = None
|
|
event = None
|
|
|
|
for o in qs:
|
|
if o.event_id != event_id:
|
|
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
|
|
event = o.event
|
|
event_id = o.event_id
|
|
|
|
if days is None:
|
|
continue
|
|
|
|
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
|
|
continue
|
|
|
|
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
if now() < reminder_date or o.datetime > reminder_date:
|
|
continue
|
|
|
|
with transaction.atomic():
|
|
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
|
|
if o.download_reminder_sent:
|
|
# Race condition
|
|
continue
|
|
positions = list(o.positions_with_tickets)
|
|
if not positions:
|
|
continue
|
|
|
|
if not o.ticket_download_available:
|
|
continue
|
|
|
|
if o.status != Order.STATUS_PAID:
|
|
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
|
|
continue
|
|
|
|
with language(o.locale, o.event.settings.region):
|
|
o.download_reminder_sent = True
|
|
o.save(update_fields=['download_reminder_sent'])
|
|
email_template = event.settings.mail_text_download_reminder
|
|
email_subject = event.settings.mail_subject_download_reminder
|
|
email_context = get_email_context(event=event, order=o)
|
|
try:
|
|
o.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.download_reminder_sent',
|
|
attach_tickets=True
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Reminder email could not be sent')
|
|
|
|
if event.settings.mail_send_download_reminder_attendee:
|
|
for p in positions:
|
|
if p.subevent_id:
|
|
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
|
|
hour=0, minute=0, second=0, microsecond=0
|
|
)
|
|
if now() < reminder_date:
|
|
continue
|
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
|
email_template = event.settings.mail_text_download_reminder_attendee
|
|
email_subject = event.settings.mail_subject_download_reminder_attendee
|
|
email_context = get_email_context(event=event, order=o, position=p)
|
|
try:
|
|
o.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.download_reminder_sent',
|
|
attach_tickets=True, position=p
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Reminder email could not be sent to attendee')
|
|
|
|
|
|
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
|
with language(order.locale, order.event.settings.region):
|
|
email_template = order.event.settings.mail_text_order_changed
|
|
email_context = get_email_context(event=order.event, order=order)
|
|
email_subject = order.event.settings.mail_subject_order_changed
|
|
try:
|
|
order.send_mail(
|
|
email_subject, email_template, email_context,
|
|
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
|
|
)
|
|
except SendMailException:
|
|
logger.exception('Order changed email could not be sent')
|
|
|
|
|
|
class OrderChangeManager:
|
|
error_messages = {
|
|
'product_without_variation': gettext_lazy('You need to select a variation of the product.'),
|
|
'quota': gettext_lazy('The quota {name} does not have enough capacity left to perform the operation.'),
|
|
'quota_missing': gettext_lazy('There is no quota defined that allows this operation.'),
|
|
'product_invalid': gettext_lazy('The selected product is not active or has no price set.'),
|
|
'complete_cancel': gettext_lazy('This operation would leave the order empty. Please cancel the order itself instead.'),
|
|
'paid_to_free_exceeded': gettext_lazy(
|
|
'This operation would make the order free and therefore immediately paid, however '
|
|
'no quota is available.'
|
|
),
|
|
'addon_to_required': gettext_lazy('This is an add-on product, please select the base position it should be added to.'),
|
|
'addon_invalid': gettext_lazy('The selected base position does not allow you to add this product as an add-on.'),
|
|
'subevent_required': gettext_lazy('You need to choose a subevent for the new position.'),
|
|
'seat_unavailable': gettext_lazy('The selected seat "{seat}" is not available.'),
|
|
'seat_subevent_mismatch': gettext_lazy(
|
|
'You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'
|
|
),
|
|
'seat_required': gettext_lazy('The selected product requires you to select a seat.'),
|
|
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
|
|
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
|
|
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
|
|
'max_items_per_product': ngettext_lazy(
|
|
"You cannot select more than %(max)s item of the product %(product)s.",
|
|
"You cannot select more than %(max)s items of the product %(product)s.",
|
|
"max"
|
|
),
|
|
'min_items_per_product': ngettext_lazy(
|
|
"You need to select at least %(min)s item of the product %(product)s.",
|
|
"You need to select at least %(min)s items of the product %(product)s.",
|
|
"min"
|
|
),
|
|
'max_order_size': gettext_lazy('Orders cannot have more than %(max)s positions.'),
|
|
}
|
|
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
|
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
|
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
|
|
PriceOperation = namedtuple('PriceOperation', ('position', 'price', 'price_diff'))
|
|
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
|
|
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
|
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
|
|
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
|
|
'valid_from', 'valid_until', 'is_bundled'))
|
|
SplitOperation = namedtuple('SplitOperation', ('position',))
|
|
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
|
|
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
|
|
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
|
|
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
|
ChangeValidFromOperation = namedtuple('ChangeValidFromOperation', ('position', 'valid_from'))
|
|
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
|
|
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
|
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
|
|
|
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
|
|
self.order = order
|
|
self.user = user
|
|
self.auth = auth
|
|
self.event = order.event
|
|
self.split_order = None
|
|
self.reissue_invoice = reissue_invoice
|
|
self._committed = False
|
|
self._totaldiff = 0
|
|
self._quotadiff = Counter()
|
|
self._seatdiff = Counter()
|
|
self._operations = []
|
|
self.notify = notify
|
|
self._invoice_dirty = False
|
|
self._invoices = []
|
|
|
|
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
|
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
|
raise OrderError(self.error_messages['product_without_variation'])
|
|
|
|
new_quotas = (variation.quotas.filter(subevent=position.subevent)
|
|
if variation else item.quotas.filter(subevent=position.subevent))
|
|
if not new_quotas:
|
|
raise OrderError(self.error_messages['quota_missing'])
|
|
|
|
self._quotadiff.update(new_quotas)
|
|
self._quotadiff.subtract(position.quotas)
|
|
self._operations.append(self.ItemOperation(position, item, variation))
|
|
|
|
def change_seat(self, position: OrderPosition, seat: Optional[Seat]):
|
|
if isinstance(seat, str):
|
|
subev = None
|
|
if self.event.has_subevents:
|
|
subev = position.subevent
|
|
for p in self._operations:
|
|
if isinstance(p, self.SubeventOperation) and p.position == position:
|
|
subev = p.subevent
|
|
try:
|
|
seat = Seat.objects.get(
|
|
event=self.event,
|
|
subevent=subev,
|
|
seat_guid=seat
|
|
)
|
|
except Seat.DoesNotExist:
|
|
raise OrderError(error_messages['seat_invalid'])
|
|
if position.seat:
|
|
self._seatdiff.subtract([position.seat])
|
|
if seat:
|
|
self._seatdiff.update([seat])
|
|
self._operations.append(self.SeatOperation(position, seat))
|
|
|
|
def change_membership(self, position: OrderPosition, membership: Membership):
|
|
self._operations.append(self.MembershipOperation(position, membership))
|
|
|
|
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
|
try:
|
|
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
|
invoice_address=self._invoice_address)
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
|
|
|
if price is None: # NOQA
|
|
raise OrderError(self.error_messages['product_invalid'])
|
|
|
|
new_quotas = (position.variation.quotas.filter(subevent=subevent)
|
|
if position.variation else position.item.quotas.filter(subevent=subevent))
|
|
if not new_quotas:
|
|
raise OrderError(self.error_messages['quota_missing'])
|
|
|
|
self._quotadiff.update(new_quotas)
|
|
self._quotadiff.subtract(position.quotas)
|
|
self._operations.append(self.SubeventOperation(position, subevent))
|
|
self._invoice_dirty = True
|
|
|
|
def change_item_and_subevent(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation],
|
|
subevent: SubEvent):
|
|
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
|
raise OrderError(self.error_messages['product_without_variation'])
|
|
|
|
try:
|
|
price = get_price(item, variation, voucher=position.voucher, subevent=subevent,
|
|
invoice_address=self._invoice_address)
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
|
|
|
if price is None: # NOQA
|
|
raise OrderError(self.error_messages['product_invalid'])
|
|
|
|
new_quotas = (variation.quotas.filter(subevent=subevent)
|
|
if variation else item.quotas.filter(subevent=subevent))
|
|
if not new_quotas:
|
|
raise OrderError(self.error_messages['quota_missing'])
|
|
|
|
self._quotadiff.update(new_quotas)
|
|
self._quotadiff.subtract(position.quotas)
|
|
self._operations.append(self.ItemOperation(position, item, variation))
|
|
self._operations.append(self.SubeventOperation(position, subevent))
|
|
self._invoice_dirty = True
|
|
|
|
def regenerate_secret(self, position: OrderPosition):
|
|
self._operations.append(self.RegenerateSecretOperation(position))
|
|
|
|
def change_valid_from(self, position: OrderPosition, new_value: datetime):
|
|
self._operations.append(self.ChangeValidFromOperation(position, new_value))
|
|
|
|
def change_valid_until(self, position: OrderPosition, new_value: datetime):
|
|
self._operations.append(self.ChangeValidUntilOperation(position, new_value))
|
|
|
|
def add_block(self, position: OrderPosition, block_name: str, ignore_from_quota_while_blocked=None):
|
|
self._operations.append(self.AddBlockOperation(position, block_name, ignore_from_quota_while_blocked))
|
|
|
|
def remove_block(self, position: OrderPosition, block_name: str, ignore_from_quota_while_blocked=None):
|
|
self._operations.append(self.RemoveBlockOperation(position, block_name, ignore_from_quota_while_blocked))
|
|
|
|
def change_price(self, position: OrderPosition, price: Decimal):
|
|
tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) or TaxRule.zero()
|
|
price = tax_rule.tax(price, base_price_is='gross')
|
|
|
|
if position.issued_gift_cards.exists():
|
|
raise OrderError(self.error_messages['gift_card_change'])
|
|
|
|
self._totaldiff += price.gross - position.price
|
|
|
|
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
|
|
self._invoice_dirty = True
|
|
|
|
self._operations.append(self.PriceOperation(position, price, price.gross - position.price))
|
|
|
|
def change_tax_rule(self, position_or_fee, tax_rule: TaxRule):
|
|
self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule))
|
|
self._invoice_dirty = True
|
|
|
|
def _current_tax_rules(self):
|
|
tax_rules = {}
|
|
for p in self._operations:
|
|
if isinstance(p, self.TaxRuleOperation):
|
|
tax_rules[p.position.pk] = p.tax_rule
|
|
elif isinstance(p, self.ItemOperation):
|
|
tax_rules[p.position.pk] = p.item.tax_rule
|
|
return tax_rules
|
|
|
|
def recalculate_taxes(self, keep='net'):
|
|
positions = self.order.positions.select_related('item', 'item__tax_rule')
|
|
ia = self._invoice_address
|
|
tax_rules = self._current_tax_rules()
|
|
|
|
for pos in positions:
|
|
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
|
|
if not tax_rule:
|
|
continue
|
|
if not pos.price:
|
|
continue
|
|
|
|
try:
|
|
new_rate = tax_rule.tax_rate_for(ia)
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(error_messages['tax_rule_country_blocked'])
|
|
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
|
|
if new_rate != pos.tax_rate:
|
|
if keep == 'net':
|
|
new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency,
|
|
override_tax_rate=new_rate)
|
|
else:
|
|
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
|
|
override_tax_rate=new_rate)
|
|
self._totaldiff += new_tax.gross - pos.price
|
|
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
|
|
self._invoice_dirty = True
|
|
|
|
def cancel_fee(self, fee: OrderFee):
|
|
self._totaldiff -= fee.value
|
|
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
|
|
self._invoice_dirty = True
|
|
|
|
def add_fee(self, fee: OrderFee):
|
|
self._totaldiff += fee.value
|
|
self._invoice_dirty = True
|
|
self._operations.append(self.AddFeeOperation(fee, fee.value))
|
|
|
|
def change_fee(self, fee: OrderFee, value: Decimal):
|
|
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
|
|
self._totaldiff += value.gross - fee.value
|
|
self._invoice_dirty = True
|
|
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
|
|
|
|
def cancel(self, position: OrderPosition):
|
|
self._totaldiff -= position.price
|
|
self._quotadiff.subtract(position.quotas)
|
|
self._operations.append(self.CancelOperation(position, -position.price))
|
|
if position.seat:
|
|
self._seatdiff.subtract([position.seat])
|
|
|
|
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
|
self._invoice_dirty = True
|
|
|
|
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
|
|
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
|
|
valid_from: datetime = None, valid_until: datetime = None):
|
|
if isinstance(seat, str):
|
|
if not seat:
|
|
seat = None
|
|
else:
|
|
try:
|
|
seat = Seat.objects.get(
|
|
event=self.event,
|
|
subevent=subevent,
|
|
seat_guid=seat
|
|
)
|
|
except Seat.DoesNotExist:
|
|
raise OrderError(error_messages['seat_invalid'])
|
|
|
|
try:
|
|
if price is None:
|
|
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
|
elif not isinstance(price, TaxedPrice):
|
|
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
|
|
|
is_bundled = False
|
|
if price is None:
|
|
raise OrderError(self.error_messages['product_invalid'])
|
|
if item.variations.exists() and not variation:
|
|
raise OrderError(self.error_messages['product_without_variation'])
|
|
if not addon_to and item.category and item.category.is_addon:
|
|
raise OrderError(self.error_messages['addon_to_required'])
|
|
if addon_to:
|
|
if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True):
|
|
if addon_to.item.bundles.filter(bundled_item=item, bundled_variation=variation).exists():
|
|
is_bundled = True
|
|
else:
|
|
raise OrderError(self.error_messages['addon_invalid'])
|
|
if self.order.event.has_subevents and not subevent:
|
|
raise OrderError(self.error_messages['subevent_required'])
|
|
|
|
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
|
|
if seated and not seat and self.event.settings.seating_choice:
|
|
raise OrderError(self.error_messages['seat_required'])
|
|
elif not seated and seat:
|
|
raise OrderError(self.error_messages['seat_forbidden'])
|
|
if seat and subevent and seat.subevent_id != subevent.pk:
|
|
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
|
|
|
|
new_quotas = (variation.quotas.filter(subevent=subevent)
|
|
if variation else item.quotas.filter(subevent=subevent))
|
|
if not new_quotas:
|
|
raise OrderError(self.error_messages['quota_missing'])
|
|
|
|
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
|
|
self._invoice_dirty = True
|
|
|
|
self._totaldiff += price.gross
|
|
self._quotadiff.update(new_quotas)
|
|
if seat:
|
|
self._seatdiff.update([seat])
|
|
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
|
|
valid_from, valid_until, is_bundled))
|
|
|
|
def split(self, position: OrderPosition):
|
|
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
|
self._invoice_dirty = True
|
|
|
|
self._operations.append(self.SplitOperation(position))
|
|
for a in position.addons.all():
|
|
self._operations.append(self.SplitOperation(a))
|
|
|
|
def set_addons(self, addons, limit_main_positions=None):
|
|
"""
|
|
This is a convenience method to change the add-on products selected on an order. The input structure is similar
|
|
to CartManager.set_addons. It will automatically compute the correct operations to add, cancel, or change
|
|
positions on the order. Every existing add-on not in the input will be canceled. Availability of the
|
|
products is validated (with some exceptions).
|
|
|
|
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
|
|
``"count"``, and ``"price"``.
|
|
:param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
|
|
queryset or a list of positions, all other positions and their add-ons will be kept
|
|
untouched.
|
|
"""
|
|
if self._operations:
|
|
raise ValueError("Setting addons should be the first/only operation")
|
|
|
|
# Prepare containers for min/max check of products
|
|
item_counts = Counter()
|
|
for p in self.order.positions.all():
|
|
item_counts[p.item] += 1
|
|
|
|
# Prepare various containers to hold data later
|
|
current_addons = defaultdict(lambda: defaultdict(list)) # OrderPos -> currently attached add-ons
|
|
input_addons = defaultdict(Counter) # OrderPos -> final desired set of add-ons
|
|
selected_addons = defaultdict(Counter) # OrderPos, ItemAddOn -> final desired set of add-ons
|
|
opcache = {} # OrderPos.pk -> OrderPos
|
|
quota_diff = Counter() # Quota -> Number of usages
|
|
available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from
|
|
price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included)
|
|
if isinstance(limit_main_positions, QuerySet):
|
|
toplevel_qs = limit_main_positions
|
|
elif limit_main_positions is not None:
|
|
toplevel_qs = self.order.positions.filter(pk__in=[p.pk for p in limit_main_positions])
|
|
else:
|
|
toplevel_qs = self.order.positions
|
|
toplevel_op = toplevel_qs.filter(
|
|
addon_to__isnull=True
|
|
).prefetch_related(
|
|
'addons', 'item__addons', 'item__addons__addon_category'
|
|
).select_related('item', 'variation')
|
|
|
|
_items_cache = {
|
|
i.pk: i
|
|
for i in self.event.items.select_related('category').prefetch_related(
|
|
'addons', 'bundles', 'addons__addon_category', 'quotas'
|
|
).annotate(
|
|
has_variations=Count('variations'),
|
|
).filter(
|
|
id__in=[a['item'] for a in addons]
|
|
).order_by()
|
|
}
|
|
_variations_cache = {
|
|
v.pk: v
|
|
for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
|
'quotas'
|
|
).select_related('item', 'item__event').filter(
|
|
id__in=[a['variation'] for a in addons if a.get('variation')]
|
|
).order_by()
|
|
}
|
|
|
|
# Prefill some of the cache containers
|
|
for op in toplevel_op:
|
|
if op.canceled:
|
|
continue
|
|
available_categories[op.pk] = {iao.addon_category_id for iao in op.item.addons.all()}
|
|
price_included[op.pk] = {iao.addon_category_id: iao.price_included for iao in op.item.addons.all()}
|
|
opcache[op.pk] = op
|
|
for a in op.addons.all():
|
|
if a.canceled:
|
|
continue
|
|
|
|
if not a.is_bundled:
|
|
current_addons[op][a.item_id, a.variation_id].append(a)
|
|
|
|
# Create operations, perform various checks
|
|
for a in addons:
|
|
# Check whether the specified items are part of what we just fetched from the database
|
|
# If they are not, the user supplied item IDs which either do not exist or belong to
|
|
# a different event
|
|
if a['item'] not in _items_cache or (a['variation'] and a['variation'] not in _variations_cache):
|
|
raise OrderError(error_messages['not_for_sale'])
|
|
|
|
# Only attach addons to things that are actually in this user's cart
|
|
if a['addon_to'] not in opcache:
|
|
raise OrderError(error_messages['addon_invalid_base'])
|
|
|
|
op = opcache[a['addon_to']]
|
|
item = _items_cache[a['item']]
|
|
subevent = op.subevent # for now, we might lift this requirement later
|
|
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
|
|
|
|
if item.category_id not in available_categories[op.pk]:
|
|
raise OrderError(error_messages['addon_invalid_base'])
|
|
|
|
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
|
quotas = list(item.quotas.filter(subevent=subevent)
|
|
if variation is None else variation.quotas.filter(subevent=subevent))
|
|
if not quotas:
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
if (a['item'], a['variation']) in input_addons[op.id]:
|
|
raise OrderError(error_messages['addon_duplicate_item'])
|
|
|
|
if item.require_voucher or item.hide_without_voucher or (variation and variation.hide_without_voucher):
|
|
raise OrderError(error_messages['voucher_required'])
|
|
|
|
if not item.is_available() or (variation and not variation.is_available()):
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
if not item.all_sales_channels:
|
|
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
if variation and not variation.all_sales_channels:
|
|
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
|
|
raise OrderError(error_messages['not_for_sale'])
|
|
|
|
if subevent and variation and variation.pk in subevent.var_overrides and \
|
|
not subevent.var_overrides[variation.pk].is_available():
|
|
raise OrderError(error_messages['not_for_sale'])
|
|
|
|
if item.has_variations and not variation:
|
|
raise OrderError(error_messages['not_for_sale'])
|
|
|
|
if variation and variation.item_id != item.pk:
|
|
raise OrderError(error_messages['not_for_sale'])
|
|
|
|
if subevent and subevent.presale_start and now() < subevent.presale_start:
|
|
raise OrderError(error_messages['not_started'])
|
|
|
|
if (subevent and subevent.presale_has_ended) or self.event.presale_has_ended:
|
|
raise OrderError(error_messages['ended'])
|
|
|
|
if item.require_bundling:
|
|
raise OrderError(error_messages['unavailable'])
|
|
|
|
input_addons[op.id][a['item'], a['variation']] = a.get('count', 1)
|
|
selected_addons[op.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
|
|
|
|
if price_included[op.pk].get(item.category_id) or (op.voucher_id and op.voucher.all_addons_included):
|
|
price = TAXED_ZERO
|
|
else:
|
|
price = get_price(
|
|
item, variation, voucher=None, custom_price=a.get('price'), subevent=op.subevent,
|
|
custom_price_is_net=self.event.settings.display_net_prices,
|
|
invoice_address=self._invoice_address,
|
|
)
|
|
|
|
if a.get('count', 1) > len(current_addons[op][a['item'], a['variation']]):
|
|
# This add-on is new, add it to the cart
|
|
for quota in quotas:
|
|
quota_diff[quota] += a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])
|
|
|
|
for i in range(a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])):
|
|
self.add_position(
|
|
item=item, variation=variation, price=price,
|
|
addon_to=op, subevent=op.subevent, seat=None,
|
|
)
|
|
item_counts[item] += 1
|
|
|
|
# Check constraints on the add-on combinations
|
|
for op in toplevel_op:
|
|
item = op.item
|
|
for iao in item.addons.all():
|
|
selected = selected_addons[op.id, iao.addon_category_id]
|
|
n_per_i = Counter()
|
|
for (i, v), c in selected.items():
|
|
n_per_i[i] += c
|
|
if sum(selected.values()) > iao.max_count:
|
|
raise OrderError(
|
|
error_messages['addon_max_count'] % {
|
|
'base': str(item.name),
|
|
'max': iao.max_count,
|
|
'cat': str(iao.addon_category.name),
|
|
}
|
|
)
|
|
elif sum(selected.values()) < iao.min_count:
|
|
raise OrderError(
|
|
error_messages['addon_min_count'] % {
|
|
'base': str(item.name),
|
|
'min': iao.min_count,
|
|
'cat': str(iao.addon_category.name),
|
|
}
|
|
)
|
|
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
|
raise OrderError(
|
|
error_messages['addon_no_multi'] % {
|
|
'base': str(item.name),
|
|
'cat': str(iao.addon_category.name),
|
|
}
|
|
)
|
|
|
|
# Detect removed add-ons and create RemoveOperations
|
|
for cp, al in list(current_addons.items()):
|
|
for k, v in al.items():
|
|
input_num = input_addons[cp.id].get(k, 0)
|
|
current_num = len(current_addons[cp].get(k, []))
|
|
if input_num < current_num:
|
|
for a in current_addons[cp][k][:current_num - input_num]:
|
|
if a.canceled:
|
|
continue
|
|
is_unavailable = (
|
|
# If an item is no longer available due to time, it should usually also be no longer
|
|
# user-removable, because e.g. the stock has already been ordered.
|
|
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
|
# not mean it should be unremovable for others.
|
|
# This also prevents accidental removal through the UI because a hidden product will no longer
|
|
# be part of the input.
|
|
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
|
or (a.variation and self.order.sales_channel not in a.variation.sales_channels)
|
|
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
|
or (
|
|
not item.all_sales_channels and
|
|
not item.limit_sales_channels.contains(self.order.sales_channel)
|
|
)
|
|
)
|
|
if is_unavailable:
|
|
continue
|
|
if a.checkins.filter(list__consider_tickets_used=True).exists():
|
|
raise OrderError(
|
|
error_messages['addon_already_checked_in'] % {
|
|
'addon': str(a.item.name),
|
|
}
|
|
)
|
|
self.cancel(a)
|
|
item_counts[a.item] -= 1
|
|
|
|
for item, count in item_counts.items():
|
|
if count == 0:
|
|
continue
|
|
|
|
if item.max_per_order and count > item.max_per_order:
|
|
raise OrderError(
|
|
self.error_messages['max_items_per_product'] % {
|
|
'max': item.max_per_order,
|
|
'product': item.name
|
|
}
|
|
)
|
|
|
|
if item.min_per_order and count < item.min_per_order:
|
|
raise OrderError(
|
|
self.error_messages['min_items_per_product'] % {
|
|
'min': item.min_per_order,
|
|
'product': item.name
|
|
}
|
|
)
|
|
|
|
def _check_seats(self):
|
|
for seat, diff in self._seatdiff.items():
|
|
if diff <= 0:
|
|
continue
|
|
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1:
|
|
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
|
|
|
|
if self.event.has_subevents:
|
|
state = {}
|
|
for p in self.order.positions.all():
|
|
state[p] = {'seat': p.seat, 'subevent': p.subevent}
|
|
for op in self._operations:
|
|
if isinstance(op, self.SeatOperation):
|
|
state[op.position]['seat'] = op.seat
|
|
elif isinstance(op, self.SubeventOperation):
|
|
state[op.position]['subevent'] = op.subevent
|
|
for v in state.values():
|
|
if v['seat'] and v['seat'].subevent_id != v['subevent'].pk:
|
|
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name))
|
|
|
|
def _check_quotas(self):
|
|
qa = QuotaAvailability()
|
|
qa.queue(*[k for k, v in self._quotadiff.items() if v > 0])
|
|
qa.compute()
|
|
for quota, diff in self._quotadiff.items():
|
|
if diff <= 0:
|
|
continue
|
|
avail = qa.results[quota]
|
|
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
|
|
raise OrderError(self.error_messages['quota'].format(name=quota.name))
|
|
|
|
def _check_paid_price_change(self):
|
|
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
|
|
if self.order.pending_sum > Decimal('0.00'):
|
|
self.order.status = Order.STATUS_PENDING
|
|
self.order.set_expires(
|
|
now(),
|
|
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
|
)
|
|
self.order.save()
|
|
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
|
|
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
|
|
self.order.status = Order.STATUS_PAID
|
|
self.order.save()
|
|
elif self.open_payment:
|
|
try:
|
|
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
|
self.order.log_action(
|
|
'pretix.event.order.payment.canceled',
|
|
{
|
|
'local_id': self.open_payment.local_id,
|
|
'provider': self.open_payment.provider,
|
|
},
|
|
user=self.user,
|
|
auth=self.auth
|
|
)
|
|
except PaymentException as e:
|
|
self.order.log_action(
|
|
'pretix.event.order.payment.canceled.failed',
|
|
{
|
|
'local_id': self.open_payment.local_id,
|
|
'provider': self.open_payment.provider,
|
|
'error': str(e)
|
|
},
|
|
user=self.user,
|
|
auth=self.auth
|
|
)
|
|
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
|
|
if self.open_payment:
|
|
try:
|
|
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
|
self.order.log_action('pretix.event.order.payment.canceled', {
|
|
'local_id': self.open_payment.local_id,
|
|
'provider': self.open_payment.provider,
|
|
}, user=self.user, auth=self.auth)
|
|
except PaymentException as e:
|
|
self.order.log_action(
|
|
'pretix.event.order.payment.canceled.failed',
|
|
{
|
|
'local_id': self.open_payment.local_id,
|
|
'provider': self.open_payment.provider,
|
|
'error': str(e)
|
|
},
|
|
user=self.user,
|
|
auth=self.auth,
|
|
)
|
|
|
|
def _check_paid_to_free(self):
|
|
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
|
|
raise OrderError(error_messages['currency_XXX'])
|
|
|
|
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
|
if not self.order.fees.exists() and not self.order.positions.exists():
|
|
# The order is completely empty now, so we cancel it.
|
|
self.order.status = Order.STATUS_CANCELED
|
|
self.order.save(update_fields=['status'])
|
|
order_canceled.send(self.order.event, order=self.order)
|
|
elif self.order.status != Order.STATUS_CANCELED:
|
|
# if the order becomes free, mark it paid using the 'free' provider
|
|
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
|
# or positions got split off to a new order (split_order with positive total)
|
|
p = self.order.payments.create(
|
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
|
provider='free',
|
|
amount=0,
|
|
fee=None
|
|
)
|
|
try:
|
|
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
|
except Quota.QuotaExceededException:
|
|
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
|
|
|
if self.split_order and self.split_order.total == 0 and not self.split_order.require_approval:
|
|
p = self.split_order.payments.create(
|
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
|
provider='free',
|
|
amount=0,
|
|
fee=None
|
|
)
|
|
try:
|
|
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
|
except Quota.QuotaExceededException:
|
|
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
|
|
|
def _perform_operations(self):
|
|
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
|
|
split_positions = []
|
|
secret_dirty = set()
|
|
|
|
for op in self._operations:
|
|
if isinstance(op, self.ItemOperation):
|
|
self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_item': op.position.item.pk,
|
|
'old_variation': op.position.variation.pk if op.position.variation else None,
|
|
'new_item': op.item.pk,
|
|
'new_variation': op.variation.pk if op.variation else None,
|
|
'old_price': op.position.price,
|
|
'addon_to': op.position.addon_to_id,
|
|
'new_price': op.position.price
|
|
})
|
|
op.position.item = op.item
|
|
op.position.variation = op.variation
|
|
op.position._calculate_tax()
|
|
|
|
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
|
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
|
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
|
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
|
else:
|
|
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
|
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
|
secret_dirty.add(op.position)
|
|
op.position.save()
|
|
elif isinstance(op, self.MembershipOperation):
|
|
self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_membership_id': op.position.used_membership_id,
|
|
'new_membership_id': op.membership.pk if op.membership else None,
|
|
})
|
|
op.position.used_membership = op.membership
|
|
op.position.save()
|
|
elif isinstance(op, self.SeatOperation):
|
|
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_seat': op.position.seat.name if op.position.seat else "-",
|
|
'new_seat': op.seat.name if op.seat else "-",
|
|
'old_seat_id': op.position.seat.pk if op.position.seat else None,
|
|
'new_seat_id': op.seat.pk if op.seat else None,
|
|
})
|
|
op.position.seat = op.seat
|
|
secret_dirty.add(op.position)
|
|
op.position.save()
|
|
elif isinstance(op, self.SubeventOperation):
|
|
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_subevent': op.position.subevent.pk,
|
|
'new_subevent': op.subevent.pk,
|
|
'old_price': op.position.price,
|
|
'new_price': op.position.price
|
|
})
|
|
op.position.subevent = op.subevent
|
|
secret_dirty.add(op.position)
|
|
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
|
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
|
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
|
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
|
else:
|
|
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
|
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
|
op.position.save()
|
|
elif isinstance(op, self.AddFeeOperation):
|
|
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
|
'fee': op.fee.pk,
|
|
})
|
|
op.fee.order = self.order
|
|
op.fee._calculate_tax()
|
|
op.fee.save()
|
|
elif isinstance(op, self.FeeValueOperation):
|
|
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
|
|
'fee': op.fee.pk,
|
|
'old_price': op.fee.value,
|
|
'new_price': op.value.gross
|
|
})
|
|
op.fee.value = op.value.gross
|
|
op.fee._calculate_tax()
|
|
op.fee.save()
|
|
elif isinstance(op, self.PriceOperation):
|
|
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_price': op.position.price,
|
|
'addon_to': op.position.addon_to_id,
|
|
'new_price': op.price.gross
|
|
})
|
|
op.position.price = op.price.gross
|
|
op.position.tax_rate = op.price.rate
|
|
op.position.tax_value = op.price.tax
|
|
op.position.save()
|
|
elif isinstance(op, self.TaxRuleOperation):
|
|
if isinstance(op.position, OrderPosition):
|
|
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'addon_to': op.position.addon_to_id,
|
|
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
|
'new_taxrule': op.tax_rule.pk
|
|
})
|
|
elif isinstance(op.position, OrderFee):
|
|
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
|
'fee': op.position.pk,
|
|
'fee_type': op.position.fee_type,
|
|
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
|
'new_taxrule': op.tax_rule.pk
|
|
})
|
|
op.position._calculate_tax(op.tax_rule)
|
|
op.position.save()
|
|
elif isinstance(op, self.CancelFeeOperation):
|
|
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
|
|
'fee': op.fee.pk,
|
|
'fee_type': op.fee.fee_type,
|
|
'old_price': op.fee.value,
|
|
})
|
|
op.fee.canceled = True
|
|
op.fee.save(update_fields=['canceled'])
|
|
elif isinstance(op, self.CancelOperation):
|
|
for gc in op.position.issued_gift_cards.all():
|
|
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
|
if gc.value < op.position.price:
|
|
raise OrderError(_(
|
|
'A position can not be canceled since the gift card {card} purchased in this order has '
|
|
'already been redeemed.').format(
|
|
card=gc.secret
|
|
))
|
|
else:
|
|
gc.transactions.create(value=-op.position.price, order=self.order, acceptor=self.order.event.organizer)
|
|
|
|
for m in op.position.granted_memberships.with_usages().all():
|
|
m.canceled = True
|
|
m.save()
|
|
|
|
for opa in op.position.addons.all():
|
|
for gc in opa.issued_gift_cards.all():
|
|
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
|
if gc.value < opa.position.price:
|
|
raise OrderError(_(
|
|
'A position can not be canceled since the gift card {card} purchased in this order has '
|
|
'already been redeemed.').format(
|
|
card=gc.secret
|
|
))
|
|
else:
|
|
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
|
|
|
|
for m in opa.granted_memberships.with_usages().all():
|
|
m.canceled = True
|
|
m.save()
|
|
|
|
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
|
'position': opa.pk,
|
|
'positionid': opa.positionid,
|
|
'old_item': opa.item.pk,
|
|
'old_variation': opa.variation.pk if opa.variation else None,
|
|
'addon_to': opa.addon_to_id,
|
|
'old_price': opa.price,
|
|
})
|
|
opa.canceled = True
|
|
if opa.voucher:
|
|
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
|
if opa in secret_dirty:
|
|
secret_dirty.remove(opa)
|
|
assign_ticket_secret(
|
|
event=self.event, position=opa, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
|
)
|
|
opa.save(update_fields=['canceled', 'secret'])
|
|
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'old_item': op.position.item.pk,
|
|
'old_variation': op.position.variation.pk if op.position.variation else None,
|
|
'old_price': op.position.price,
|
|
'addon_to': None,
|
|
})
|
|
op.position.canceled = True
|
|
if op.position.voucher:
|
|
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
|
assign_ticket_secret(
|
|
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
|
)
|
|
if op.position in secret_dirty:
|
|
secret_dirty.remove(op.position)
|
|
op.position.save(update_fields=['canceled', 'secret'])
|
|
elif isinstance(op, self.AddOperation):
|
|
pos = OrderPosition.objects.create(
|
|
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
|
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
|
|
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
|
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
|
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
|
is_bundled=op.is_bundled,
|
|
)
|
|
nextposid += 1
|
|
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
|
'position': pos.pk,
|
|
'item': op.item.pk,
|
|
'variation': op.variation.pk if op.variation else None,
|
|
'addon_to': op.addon_to.pk if op.addon_to else None,
|
|
'price': op.price.gross,
|
|
'positionid': pos.positionid,
|
|
'membership': pos.used_membership_id,
|
|
'subevent': op.subevent.pk if op.subevent else None,
|
|
'seat': op.seat.pk if op.seat else None,
|
|
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
|
|
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
|
|
})
|
|
elif isinstance(op, self.SplitOperation):
|
|
split_positions.append(op.position)
|
|
elif isinstance(op, self.RegenerateSecretOperation):
|
|
assign_ticket_secret(
|
|
event=self.event, position=op.position, force_invalidate=True, save=True
|
|
)
|
|
if op.position in secret_dirty:
|
|
secret_dirty.remove(op.position)
|
|
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
|
|
'order': self.order.pk})
|
|
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
})
|
|
elif isinstance(op, self.ChangeValidFromOperation):
|
|
self.order.log_action('pretix.event.order.changed.valid_from', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'new_value': op.valid_from.isoformat() if op.valid_from else None,
|
|
'old_value': op.position.valid_from.isoformat() if op.position.valid_from else None,
|
|
})
|
|
op.position.valid_from = op.valid_from
|
|
op.position.save(update_fields=['valid_from'])
|
|
secret_dirty.add(op.position)
|
|
elif isinstance(op, self.ChangeValidUntilOperation):
|
|
self.order.log_action('pretix.event.order.changed.valid_until', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'new_value': op.valid_until.isoformat() if op.valid_until else None,
|
|
'old_value': op.position.valid_until.isoformat() if op.position.valid_until else None,
|
|
})
|
|
op.position.valid_until = op.valid_until
|
|
op.position.save(update_fields=['valid_until'])
|
|
secret_dirty.add(op.position)
|
|
elif isinstance(op, self.AddBlockOperation):
|
|
self.order.log_action('pretix.event.order.changed.add_block', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'block_name': op.block_name,
|
|
})
|
|
if op.position.blocked:
|
|
if op.block_name not in op.position.blocked:
|
|
op.position.blocked = op.position.blocked + [op.block_name]
|
|
else:
|
|
op.position.blocked = [op.block_name]
|
|
if op.ignore_from_quota_while_blocked is not None:
|
|
op.position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked
|
|
op.position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked'])
|
|
if op.position.blocked:
|
|
op.position.blocked_secrets.update_or_create(
|
|
event=self.event,
|
|
secret=op.position.secret,
|
|
defaults={
|
|
'blocked': True,
|
|
'updated': now(),
|
|
}
|
|
)
|
|
elif isinstance(op, self.RemoveBlockOperation):
|
|
self.order.log_action('pretix.event.order.changed.remove_block', user=self.user, auth=self.auth, data={
|
|
'position': op.position.pk,
|
|
'positionid': op.position.positionid,
|
|
'block_name': op.block_name,
|
|
})
|
|
if op.position.blocked and op.block_name in op.position.blocked:
|
|
op.position.blocked = [b for b in op.position.blocked if b != op.block_name]
|
|
if not op.position.blocked:
|
|
op.position.blocked = None
|
|
if op.ignore_from_quota_while_blocked is not None:
|
|
op.position.ignore_from_quota_while_blocked = op.ignore_from_quota_while_blocked
|
|
op.position.save(update_fields=['blocked', 'ignore_from_quota_while_blocked'])
|
|
if not op.position.blocked:
|
|
try:
|
|
bs = op.position.blocked_secrets.get(secret=op.position.secret)
|
|
bs.blocked = False
|
|
bs.save()
|
|
except BlockedTicketSecret.DoesNotExist:
|
|
pass
|
|
# todo: revoke list handling
|
|
|
|
for p in secret_dirty:
|
|
assign_ticket_secret(
|
|
event=self.event, position=p, force_invalidate=False, save=True
|
|
)
|
|
|
|
if split_positions:
|
|
self.split_order = self._create_split_order(split_positions)
|
|
|
|
def _create_split_order(self, split_positions):
|
|
split_order = Order.objects.get(pk=self.order.pk)
|
|
split_order.pk = None
|
|
split_order.code = None
|
|
split_order.datetime = now()
|
|
split_order.secret = generate_secret()
|
|
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
|
|
split_order.save()
|
|
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
|
'original_order': self.order.code
|
|
})
|
|
|
|
for op in split_positions:
|
|
self.order.log_action('pretix.event.order.changed.split', user=self.user, auth=self.auth, data={
|
|
'position': op.pk,
|
|
'positionid': op.positionid,
|
|
'old_item': op.item.pk,
|
|
'old_variation': op.variation.pk if op.variation else None,
|
|
'old_price': op.price,
|
|
'new_order': split_order.code,
|
|
})
|
|
op.order = split_order
|
|
assign_ticket_secret(
|
|
self.event, position=op, force_invalidate=True,
|
|
)
|
|
op.save()
|
|
|
|
try:
|
|
ia = modelcopy(self.order.invoice_address)
|
|
ia.pk = None
|
|
ia.order = split_order
|
|
ia.save()
|
|
except InvoiceAddress.DoesNotExist:
|
|
pass
|
|
|
|
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
|
|
|
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
|
|
new_fee = modelcopy(fee)
|
|
new_fee.pk = None
|
|
new_fee.order = split_order
|
|
split_order.total += new_fee.value
|
|
new_fee.save()
|
|
|
|
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
|
pp = self._get_payment_provider()
|
|
if pp:
|
|
payment_fee = pp.calculate_fee(split_order.total)
|
|
else:
|
|
payment_fee = Decimal('0.00')
|
|
fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
|
|
fee.value = payment_fee
|
|
fee._calculate_tax()
|
|
if payment_fee != 0:
|
|
fee.save()
|
|
elif fee.pk:
|
|
fee.delete()
|
|
split_order.total += fee.value
|
|
|
|
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
|
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
|
if offset_amount >= split_order.total and not split_order.require_approval:
|
|
split_order.status = Order.STATUS_PAID
|
|
else:
|
|
split_order.status = Order.STATUS_PENDING
|
|
if self.order.status == Order.STATUS_PAID:
|
|
split_order.set_expires(
|
|
now(),
|
|
list(set(p.subevent_id for p in split_positions))
|
|
)
|
|
split_order.save()
|
|
|
|
if offset_amount > Decimal('0.00'):
|
|
split_order.payments.create(
|
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
|
amount=offset_amount,
|
|
payment_date=now(),
|
|
provider='offsetting',
|
|
info=json.dumps({'orders': [self.order.code]})
|
|
)
|
|
self.order.refunds.create(
|
|
state=OrderRefund.REFUND_STATE_DONE,
|
|
amount=offset_amount,
|
|
execution_date=now(),
|
|
provider='offsetting',
|
|
info=json.dumps({'orders': [split_order.code]})
|
|
)
|
|
|
|
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
|
|
generate_invoice(split_order)
|
|
|
|
order_split.send(sender=self.order.event, original=self.order, split_order=split_order)
|
|
return split_order
|
|
|
|
@cached_property
|
|
def open_payment(self):
|
|
lp = self.order.payments.last()
|
|
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED,
|
|
OrderPayment.PAYMENT_STATE_REFUNDED):
|
|
return lp
|
|
|
|
@cached_property
|
|
def completed_payment_sum(self):
|
|
payment_sum = self.order.payments.filter(
|
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
|
refund_sum = self.order.refunds.filter(
|
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE)
|
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
|
return payment_sum - refund_sum
|
|
|
|
def _recalculate_total_and_payment_fee(self):
|
|
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
|
payment_fee = Decimal('0.00')
|
|
if self.open_payment:
|
|
current_fee = Decimal('0.00')
|
|
fee = None
|
|
if self.open_payment.fee:
|
|
fee = self.open_payment.fee
|
|
if any(isinstance(op, (self.FeeValueOperation, self.CancelFeeOperation)) for op in self._operations):
|
|
fee.refresh_from_db()
|
|
if not self.open_payment.fee.canceled:
|
|
current_fee = self.open_payment.fee.value
|
|
total -= current_fee
|
|
|
|
if fee and any([isinstance(op, self.FeeValueOperation) and op.fee == fee for op in self._operations]):
|
|
# Do not automatically modify a fee that is being manually modified right now
|
|
payment_fee = fee.value
|
|
elif fee and any([isinstance(op, self.CancelFeeOperation) and op.fee == fee for op in self._operations]):
|
|
# Do not automatically modify a fee that is being manually removed right now
|
|
payment_fee = Decimal('0.00')
|
|
elif self.order.pending_sum - current_fee != 0:
|
|
prov = self.open_payment.payment_provider
|
|
if prov:
|
|
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
|
|
|
|
if payment_fee:
|
|
fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order)
|
|
fee.value = payment_fee
|
|
fee._calculate_tax()
|
|
fee.save()
|
|
if not self.open_payment.fee:
|
|
self.open_payment.fee = fee
|
|
self.open_payment.save(update_fields=['fee'])
|
|
elif fee and not fee.canceled:
|
|
fee.delete()
|
|
|
|
self.order.total = total + payment_fee
|
|
self.order.save()
|
|
|
|
def _check_order_size(self):
|
|
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
|
raise OrderError(
|
|
self.error_messages['max_order_size'] % {
|
|
'max': settings.PRETIX_MAX_ORDER_SIZE,
|
|
}
|
|
)
|
|
|
|
def _payment_fee_diff(self):
|
|
total = self.order.total + self._totaldiff
|
|
if self.open_payment:
|
|
current_fee = Decimal('0.00')
|
|
if self.open_payment and self.open_payment.fee:
|
|
current_fee = self.open_payment.fee.value
|
|
total -= current_fee
|
|
|
|
# Do not change payment fees of paid orders
|
|
payment_fee = Decimal('0.00')
|
|
if self.order.pending_sum - current_fee != 0:
|
|
prov = self.open_payment.payment_provider
|
|
if prov:
|
|
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
|
|
|
|
self._totaldiff += payment_fee - current_fee
|
|
|
|
def _reissue_invoice(self):
|
|
i = self.order.invoices.filter(is_cancellation=False).last()
|
|
if self.reissue_invoice and self._invoice_dirty:
|
|
order_now_qualified = invoice_qualified(self.order)
|
|
invoice_should_be_generated_now = (
|
|
self.event.settings.invoice_generate == "True" or (
|
|
self.event.settings.invoice_generate == "paid" and
|
|
self.open_payment is not None and
|
|
self.open_payment.payment_provider.requires_invoice_immediately
|
|
) or (
|
|
self.event.settings.invoice_generate == "paid" and
|
|
self.order.status == Order.STATUS_PAID
|
|
) or (
|
|
# Backwards-compatible behaviour
|
|
self.event.settings.invoice_generate not in ("True", "paid") and
|
|
i and
|
|
not i.canceled
|
|
)
|
|
)
|
|
invoice_should_be_generated_later = not invoice_should_be_generated_now and (
|
|
self.event.settings.invoice_generate in ("True", "paid")
|
|
)
|
|
|
|
if order_now_qualified:
|
|
if invoice_should_be_generated_now:
|
|
if i and not i.canceled:
|
|
self._invoices.append(generate_cancellation(i))
|
|
self._invoices.append(generate_invoice(self.order))
|
|
elif invoice_should_be_generated_later:
|
|
self.order.invoice_dirty = True
|
|
self.order.save(update_fields=["invoice_dirty"])
|
|
else:
|
|
if i and not i.canceled:
|
|
self._invoices.append(generate_cancellation(i))
|
|
|
|
def _check_complete_cancel(self):
|
|
current = self.order.positions.count()
|
|
cancels = len([o for o in self._operations if isinstance(o, (self.CancelOperation, self.SplitOperation))])
|
|
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
|
|
if current > 0 and current - cancels + adds < 1:
|
|
raise OrderError(self.error_messages['complete_cancel'])
|
|
|
|
@property
|
|
def _invoice_address(self):
|
|
try:
|
|
return self.order.invoice_address
|
|
except InvoiceAddress.DoesNotExist:
|
|
return None
|
|
|
|
def _check_and_lock_memberships(self):
|
|
# To avoid duplicating all the logic around memberships, we simulate an application of all relevant
|
|
# operations in a non-existing cart and then pass that to our cart checker.
|
|
fake_cart = []
|
|
positions_to_fake_cart = {}
|
|
|
|
for p in self.order.positions.all():
|
|
cp = CartPosition(
|
|
event=self.event,
|
|
item=p.item,
|
|
variation=p.variation,
|
|
attendee_name_parts=p.attendee_name_parts,
|
|
used_membership=p.used_membership,
|
|
subevent=p.subevent,
|
|
seat=p.seat,
|
|
)
|
|
fake_cart.append(cp)
|
|
positions_to_fake_cart[p] = cp
|
|
|
|
for op in self._operations:
|
|
if isinstance(op, self.ItemOperation):
|
|
positions_to_fake_cart[op.position].item = op.item
|
|
positions_to_fake_cart[op.position].variation = op.variation
|
|
elif isinstance(op, self.SubeventOperation):
|
|
positions_to_fake_cart[op.position].subevent = op.subevent
|
|
elif isinstance(op, self.SeatOperation):
|
|
positions_to_fake_cart[op.position].seat = op.seat
|
|
elif isinstance(op, self.MembershipOperation):
|
|
positions_to_fake_cart[op.position].used_membership = op.membership
|
|
elif isinstance(op, self.ChangeValidFromOperation):
|
|
positions_to_fake_cart[op.position].override_valid_from = op.valid_from
|
|
elif isinstance(op, self.ChangeValidUntilOperation):
|
|
positions_to_fake_cart[op.position].override_valid_until = op.valid_until
|
|
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
|
fake_cart.remove(positions_to_fake_cart[op.position])
|
|
elif isinstance(op, self.AddOperation):
|
|
cp = CartPosition(
|
|
event=self.event,
|
|
item=op.item,
|
|
variation=op.variation,
|
|
used_membership=op.membership,
|
|
subevent=op.subevent,
|
|
seat=op.seat,
|
|
)
|
|
cp.override_valid_from = op.valid_from
|
|
cp.override_valid_until = op.valid_until
|
|
fake_cart.append(cp)
|
|
try:
|
|
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
|
except ValidationError as e:
|
|
raise OrderError(e.message)
|
|
|
|
def _create_locks(self):
|
|
full_lock_required = any(diff > 0 for diff in self._seatdiff.values()) and self.event.settings.seating_minimal_distance > 0
|
|
if full_lock_required:
|
|
# We lock the entire event in this case since we don't want to deal with fine-granular locking
|
|
# in the case of seating distance enforcement
|
|
lock_objects([self.event])
|
|
else:
|
|
lock_objects(
|
|
[q for q, d in self._quotadiff.items() if q.size is not None and d > 0] +
|
|
[s for s, d in self._seatdiff.items() if d > 0],
|
|
shared_lock_objects=[self.event]
|
|
)
|
|
|
|
def commit(self, check_quotas=True):
|
|
if self._committed:
|
|
# an order change can only be committed once
|
|
raise OrderError(error_messages['internal'])
|
|
self._committed = True
|
|
|
|
if not self._operations:
|
|
# Do nothing
|
|
return
|
|
|
|
# Clear prefetched objects cache of order. We're going to modify the positions and fees and we have no guarantee
|
|
# that every operation tuple points to a position/fee instance that has been fetched from the same object cache,
|
|
# so it's dangerous to keep the cache around.
|
|
self.order._prefetched_objects_cache = {}
|
|
|
|
# finally, incorporate difference in payment fees
|
|
self._payment_fee_diff()
|
|
self._check_order_size()
|
|
|
|
with transaction.atomic():
|
|
locked_instance = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk)
|
|
if locked_instance.last_modified != self.order.last_modified:
|
|
raise OrderError(error_messages['race_condition'])
|
|
|
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
|
if check_quotas:
|
|
self._check_quotas()
|
|
self._check_seats()
|
|
self._create_locks()
|
|
self._check_complete_cancel()
|
|
self._check_and_lock_memberships()
|
|
try:
|
|
self._perform_operations()
|
|
except TaxRule.SaleNotAllowed:
|
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
|
self._recalculate_total_and_payment_fee()
|
|
self._check_paid_price_change()
|
|
self._check_paid_to_free()
|
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
|
self._reissue_invoice()
|
|
self._clear_tickets_cache()
|
|
self.order.touch()
|
|
self.order.create_transactions()
|
|
if self.split_order:
|
|
self.split_order.create_transactions()
|
|
|
|
if self.notify:
|
|
notify_user_changed_order(
|
|
self.order, self.user, self.auth,
|
|
self._invoices if self.event.settings.invoice_email_attachment else []
|
|
)
|
|
if self.split_order:
|
|
notify_user_changed_order(
|
|
self.split_order, self.user, self.auth,
|
|
list(self.split_order.invoices.all()) if self.event.settings.invoice_email_attachment else []
|
|
)
|
|
|
|
order_changed.send(self.order.event, order=self.order)
|
|
|
|
def _clear_tickets_cache(self):
|
|
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
|
|
'order': self.order.pk})
|
|
if self.split_order:
|
|
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
|
|
'order': self.split_order.pk})
|
|
|
|
def _get_payment_provider(self):
|
|
lp = self.order.payments.last()
|
|
if not lp:
|
|
return None
|
|
pprov = lp.payment_provider
|
|
if not pprov:
|
|
return None
|
|
return pprov
|
|
|
|
|
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
|
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
|
|
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
|
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None):
|
|
with language(locale), time_machine_now_assigned(override_now_dt):
|
|
try:
|
|
try:
|
|
return _perform_order(event, payments, positions, email, locale, address, meta_info,
|
|
sales_channel, shown_total, customer)
|
|
except LockTimeoutException:
|
|
self.retry()
|
|
except (MaxRetriesExceededError, LockTimeoutException):
|
|
raise OrderError(error_messages['busy'])
|
|
|
|
|
|
_unset = object()
|
|
|
|
|
|
def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial=False,
|
|
source=OrderRefund.REFUND_SOURCE_BUYER,
|
|
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
|
notify_admin = False
|
|
error = False
|
|
if isinstance(order, int):
|
|
order = Order.objects.get(pk=order)
|
|
refund_amount = order.pending_sum * -1
|
|
if refund_amount <= Decimal('0.00'):
|
|
return
|
|
|
|
can_auto_refund_sum = 0
|
|
|
|
if refund_as_giftcard:
|
|
can_auto_refund_sum = refund_amount
|
|
with transaction.atomic():
|
|
giftcard = order.event.organizer.issued_gift_cards.create(
|
|
expires=order.event.organizer.default_gift_card_expiry if giftcard_expires is _unset else giftcard_expires,
|
|
conditions=giftcard_conditions,
|
|
currency=order.event.currency,
|
|
testmode=order.testmode
|
|
)
|
|
giftcard.log_action('pretix.giftcards.created', data={})
|
|
r = order.refunds.create(
|
|
order=order,
|
|
payment=None,
|
|
source=source,
|
|
comment=comment,
|
|
state=OrderRefund.REFUND_STATE_CREATED,
|
|
execution_date=now(),
|
|
amount=can_auto_refund_sum,
|
|
provider='giftcard',
|
|
info=json.dumps({
|
|
'gift_card': giftcard.pk
|
|
})
|
|
)
|
|
try:
|
|
r.payment_provider.execute_refund(r)
|
|
except PaymentException as e:
|
|
with transaction.atomic():
|
|
r.state = OrderRefund.REFUND_STATE_FAILED
|
|
r.save()
|
|
order.log_action('pretix.event.order.refund.failed', {
|
|
'local_id': r.local_id,
|
|
'provider': r.provider,
|
|
'error': str(e)
|
|
})
|
|
error = True
|
|
notify_admin = True
|
|
else:
|
|
if r.state != OrderRefund.REFUND_STATE_DONE:
|
|
notify_admin = True
|
|
|
|
elif auto_refund:
|
|
proposals = order.propose_auto_refunds(refund_amount)
|
|
can_auto_refund_sum = sum(proposals.values())
|
|
if (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount:
|
|
for p, value in proposals.items():
|
|
with transaction.atomic():
|
|
r = order.refunds.create(
|
|
payment=p,
|
|
source=source,
|
|
state=OrderRefund.REFUND_STATE_CREATED,
|
|
amount=value,
|
|
comment=comment,
|
|
provider=p.provider
|
|
)
|
|
order.log_action('pretix.event.order.refund.created', {
|
|
'local_id': r.local_id,
|
|
'provider': r.provider,
|
|
})
|
|
|
|
try:
|
|
r.payment_provider.execute_refund(r)
|
|
except PaymentException as e:
|
|
with transaction.atomic():
|
|
r.state = OrderRefund.REFUND_STATE_FAILED
|
|
r.save()
|
|
order.log_action('pretix.event.order.refund.failed', {
|
|
'local_id': r.local_id,
|
|
'provider': r.provider,
|
|
'error': str(e)
|
|
})
|
|
error = True
|
|
notify_admin = True
|
|
else:
|
|
if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
|
|
notify_admin = True
|
|
|
|
if refund_amount - can_auto_refund_sum > Decimal('0.00'):
|
|
if manual_refund:
|
|
with transaction.atomic():
|
|
r = order.refunds.create(
|
|
source=source,
|
|
comment=comment,
|
|
state=OrderRefund.REFUND_STATE_CREATED,
|
|
amount=refund_amount - can_auto_refund_sum,
|
|
provider='manual'
|
|
)
|
|
order.log_action('pretix.event.order.refund.created', {
|
|
'local_id': r.local_id,
|
|
'provider': r.provider,
|
|
})
|
|
else:
|
|
notify_admin = True
|
|
|
|
if notify_admin:
|
|
order.log_action('pretix.event.order.refund.requested')
|
|
if error:
|
|
raise OrderError(
|
|
_(
|
|
'There was an error while trying to send the money back to you. Please contact the event organizer '
|
|
'for further information.')
|
|
)
|
|
|
|
|
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
|
@scopes_disabled()
|
|
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
|
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
|
|
email_comment=None, refund_comment=None, cancel_invoice=True):
|
|
try:
|
|
try:
|
|
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
|
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment)
|
|
if try_auto_refund:
|
|
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
|
comment=refund_comment)
|
|
return ret
|
|
except LockTimeoutException:
|
|
self.retry()
|
|
except (MaxRetriesExceededError, LockTimeoutException):
|
|
raise OrderError(error_messages['busy'])
|
|
|
|
|
|
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None, create_log=True,
|
|
recreate_invoices=True):
|
|
if not get_connection().in_atomic_block:
|
|
raise Exception('change_payment_provider should only be called in atomic transaction!')
|
|
|
|
oldtotal = order.total
|
|
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
|
|
OrderPayment.PAYMENT_STATE_REFUNDED))
|
|
open_fees = list(
|
|
order.fees.annotate(has_p=Exists(e)).filter(
|
|
Q(fee_type=OrderFee.FEE_TYPE_PAYMENT) & ~Q(has_p=True)
|
|
)
|
|
)
|
|
if open_fees:
|
|
fee = open_fees[0]
|
|
if len(open_fees) > 1:
|
|
for f in open_fees[1:]:
|
|
f.delete()
|
|
else:
|
|
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
|
|
old_fee = fee.value
|
|
|
|
new_fee = payment_provider.calculate_fee(
|
|
order.pending_sum - old_fee if amount is None else amount
|
|
)
|
|
if new_fee:
|
|
fee.value = new_fee
|
|
fee.internal_type = payment_provider.identifier
|
|
fee._calculate_tax()
|
|
fee.save()
|
|
else:
|
|
if fee.pk:
|
|
fee.delete()
|
|
fee = None
|
|
|
|
open_payment = None
|
|
if new_payment:
|
|
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
|
|
else:
|
|
lp = order.payments.select_for_update(of=OF_SELF).last()
|
|
|
|
if lp and lp.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
|
open_payment = lp
|
|
|
|
if open_payment:
|
|
try:
|
|
open_payment.payment_provider.cancel_payment(open_payment)
|
|
order.log_action('pretix.event.order.payment.canceled', {
|
|
'local_id': open_payment.local_id,
|
|
'provider': open_payment.provider,
|
|
})
|
|
except PaymentException as e:
|
|
order.log_action(
|
|
'pretix.event.order.payment.canceled.failed',
|
|
{
|
|
'local_id': open_payment.local_id,
|
|
'provider': open_payment.provider,
|
|
'error': str(e)
|
|
},
|
|
)
|
|
|
|
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
|
|
order.save(update_fields=['total'])
|
|
|
|
if not new_payment:
|
|
new_payment = order.payments.create(
|
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
|
provider=payment_provider.identifier,
|
|
amount=order.pending_sum,
|
|
fee=fee
|
|
)
|
|
if create_log and new_payment:
|
|
order.log_action(
|
|
'pretix.event.order.payment.changed' if open_payment else 'pretix.event.order.payment.started',
|
|
{
|
|
'fee': new_fee,
|
|
'old_fee': old_fee,
|
|
'provider': payment_provider.identifier,
|
|
'payment': new_payment.pk,
|
|
'local_id': new_payment.local_id,
|
|
}
|
|
)
|
|
|
|
if recreate_invoices:
|
|
i = order.invoices.filter(is_cancellation=False).last()
|
|
if i and order.total != oldtotal and not i.canceled:
|
|
generate_cancellation(i)
|
|
generate_invoice(order)
|
|
|
|
order.create_transactions()
|
|
return old_fee, new_fee, fee, new_payment
|
|
|
|
|
|
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards")
|
|
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_giftcards")
|
|
@transaction.atomic()
|
|
def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
|
|
if order.status != Order.STATUS_PAID:
|
|
return
|
|
any_giftcards = False
|
|
for p in order.positions.all():
|
|
if p.item.issue_giftcard:
|
|
issued = Decimal('0.00')
|
|
for gc in p.issued_gift_cards.all():
|
|
issued += gc.transactions.first().value
|
|
if p.price - issued > 0:
|
|
gc = sender.organizer.issued_gift_cards.create(
|
|
currency=sender.currency, issued_in=p, testmode=order.testmode,
|
|
expires=sender.organizer.default_gift_card_expiry,
|
|
)
|
|
gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
|
|
any_giftcards = True
|
|
p.secret = gc.secret
|
|
p.save(update_fields=['secret'])
|
|
|
|
if any_giftcards:
|
|
tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk})
|
|
|
|
|
|
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_memberships")
|
|
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_memberships")
|
|
@transaction.atomic()
|
|
def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
|
|
if order.status != Order.STATUS_PAID or not order.customer:
|
|
return
|
|
for p in order.positions.all():
|
|
if p.item.grant_membership_type_id:
|
|
create_membership(order.customer, p)
|
|
|
|
|
|
@receiver(order_placed, dispatch_uid="pretixbase_order_placed_media")
|
|
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_media")
|
|
@transaction.atomic()
|
|
def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
|
from pretix.base.models import ReusableMedium
|
|
|
|
for p in order.positions.all():
|
|
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
|
mt = MEDIA_TYPES[p.item.media_type]
|
|
if mt.medium_created_by_server and not p.linked_media.exists():
|
|
rm = ReusableMedium.objects.create(
|
|
organizer=sender.organizer,
|
|
type=p.item.media_type,
|
|
identifier=mt.generate_identifier(sender.organizer),
|
|
active=True,
|
|
customer=order.customer,
|
|
linked_orderposition=p,
|
|
)
|
|
rm.log_action(
|
|
'pretix.reusable_medium.created',
|
|
data={
|
|
'by_order': order.code,
|
|
'linked_orderposition': p.pk,
|
|
'active': True,
|
|
'customer': order.customer_id,
|
|
}
|
|
)
|