Compare commits

...

9 Commits

Author SHA1 Message Date
Raphael Michel
dc0b73bf19 Fix issues introduced in rebase 2016-11-27 17:13:26 +01:00
Raphael Michel
ed31f31c04 Added a test for the cart methods 2016-11-27 16:13:58 +01:00
Raphael Michel
b1e78b5b78 Fix failing tests 2016-11-27 16:13:58 +01:00
Raphael Michel
4e2d31154a Fix dummy lock function 2016-11-27 16:13:58 +01:00
Raphael Michel
2e5a598b5f Restructure checkout to reduce locking times 2016-11-27 16:13:58 +01:00
Raphael Michel
4b535b067a Move two calls out of the lock period in OrderChangeManager 2016-11-27 16:13:58 +01:00
Raphael Michel
4f6eb903c7 mark_order_paid: Only lock when necessary 2016-11-27 16:13:58 +01:00
Raphael Michel
4d916df7c0 Restructure add_to_cart 2016-11-27 16:13:57 +01:00
Raphael Michel
61a331493e Reduce locked timeframe in add_items_to_cart 2016-11-27 16:12:38 +01:00
7 changed files with 305 additions and 165 deletions

View File

@@ -279,11 +279,12 @@ class Order(LoggedModel):
if now() > last_date: if now() > last_date:
return error_messages['late'] return error_messages['late']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late'): if not self.event.settings.get('payment_term_accept_late'):
return error_messages['late'] return error_messages['late']
if self.status == self.STATUS_PENDING:
return True
else:
return self._is_still_available() return self._is_still_available()
def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]:

View File

@@ -1,9 +1,11 @@
from collections import Counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import List, Optional from typing import List
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from pretix.base.i18n import LazyLocaleException from pretix.base.i18n import LazyLocaleException
@@ -35,8 +37,6 @@ error_messages = {
'voucher_invalid': _('This voucher code is not known in our database.'), 'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'), 'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'), 'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
'cart if you want to use it for a different product.'),
'voucher_expired': _('This voucher is expired.'), 'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'), 'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_required': _('You need a valid voucher code to order this product.'), 'voucher_required': _('You need a valid voucher code to order this product.'),
@@ -65,7 +65,7 @@ def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now
'variation': cp.variation_id, 'variation': cp.variation_id,
'count': 1, 'count': 1,
'price': cp.price, 'price': cp.price,
'cp': cp, '_cp': cp,
'voucher': cp.voucher.code if cp.voucher else None 'voucher': cp.voucher.code if cp.voucher else None
}) })
positions.add(cp) positions.add(cp)
@@ -74,7 +74,7 @@ def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now
def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None: def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None:
for cp in expired: for cp in expired:
if cp.expires <= now_dt: if cp.expires <= now_dt: # Has not been extended
cp.delete() cp.delete()
@@ -85,8 +85,17 @@ def _check_date(event: Event, now_dt: datetime) -> None:
raise CartError(error_messages['ended']) raise CartError(error_messages['ended'])
def _add_new_items(event: Event, items: List[dict], def _parse_items_and_check_constraints(event: Event, items: List[dict], cart_id: str,
cart_id: str, expiry: datetime, now_dt: datetime) -> Optional[str]: now_dt: datetime) -> Counter:
"""
This method does three things:
* Extend the item list with the database objects for the item, variation, etc.
* Check all constraints that are placed on the items, vouchers etc. to be valid and calculates the correct prices
* Return a counter object that contains the quota changes that are required to perform the operation
"""
err = None err = None
# Fetch items from the database # Fetch items from the database
@@ -99,6 +108,9 @@ def _add_new_items(event: Event, items: List[dict],
).select_related("item", "item__event").prefetch_related("quotas") ).select_related("item", "item__event").prefetch_related("quotas")
variations_cache = {v.id: v for v in variations_query} variations_cache = {v.id: v for v in variations_query}
quotadiff = Counter()
vouchers = Counter()
for i in items: for i in items:
# Check whether the specified items are part of what we just fetched from the database # 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 # If they are not, the user supplied item IDs which either do not exist or belong to
@@ -116,58 +128,44 @@ def _add_new_items(event: Event, items: List[dict],
try: try:
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event) voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
if voucher.redeemed >= voucher.max_usages: if voucher.redeemed >= voucher.max_usages:
return error_messages['voucher_redeemed'] raise CartError(error_messages['voucher_redeemed'])
if voucher.valid_until is not None and voucher.valid_until < now_dt: if voucher.valid_until is not None and voucher.valid_until < now_dt:
return error_messages['voucher_expired'] raise CartError(error_messages['voucher_expired'])
if not voucher.applies_to(item, variation): if not voucher.applies_to(item, variation):
return error_messages['voucher_invalid_item'] raise CartError(error_messages['voucher_invalid_item'])
redeemed_in_carts = CartPosition.objects.filter( redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=event) & Q(voucher=voucher) & Q(event=event) & Q(expires__gte=now_dt)
(Q(expires__gte=now_dt) | Q(cart_id=cart_id))
) )
if 'cp' in i: if 'cp' in i:
redeemed_in_carts = redeemed_in_carts.exclude(pk=i['cp'].pk) redeemed_in_carts = redeemed_in_carts.exclude(pk=i['_cp'].pk)
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count() v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1: if v_avail < 1:
return error_messages['voucher_redeemed'] raise CartError(error_messages['voucher_redeemed'])
if i['count'] > v_avail: if i['count'] > v_avail - vouchers[voucher]:
return error_messages['voucher_redeemed_partial'] % v_avail raise CartError(error_messages['voucher_redeemed_partial'] % v_avail)
vouchers[voucher] += i['count']
except Voucher.DoesNotExist: except Voucher.DoesNotExist:
return error_messages['voucher_invalid'] raise CartError(error_messages['voucher_invalid'])
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold. # Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
if voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]: if voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]:
return error_messages['voucher_invalid_item'] raise CartError(error_messages['voucher_invalid_item'])
if item.require_voucher and voucher is None: if item.require_voucher and voucher is None:
return error_messages['voucher_required'] raise CartError(error_messages['voucher_required'])
if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk): if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk):
return error_messages['voucher_required'] raise CartError(error_messages['voucher_required'])
if len(quotas) == 0 or not item.is_available() or (variation and not variation.active): if len(quotas) == 0 or not item.is_available() or (variation and not variation.active):
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
continue continue
# Check that all quotas allow us to buy i['count'] instances of the object
quota_ok = i['count']
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
for quota in quotas:
avail = quota.availability()
if avail[1] is not None and avail[1] < i['count']:
# This quota is not available or less than i['count'] items are left, so we have to
# reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable']
else:
err = err or error_messages['in_part']
quota_ok = min(quota_ok, avail[1])
if voucher and voucher.price is not None: if voucher and voucher.price is not None:
price = voucher.price price = voucher.price
else: else:
@@ -179,30 +177,91 @@ def _add_new_items(event: Event, items: List[dict],
if not isinstance(custom_price, Decimal): if not isinstance(custom_price, Decimal):
custom_price = Decimal(custom_price.replace(",", ".")) custom_price = Decimal(custom_price.replace(",", "."))
if custom_price > 100000000: if custom_price > 100000000:
return error_messages['price_too_high'] raise CartError(error_messages['price_too_high'])
price = max(custom_price, price) price = max(custom_price, price)
# Check that all quotas allow us to buy i['count'] instances of the object
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
for quota in quotas:
quotadiff[quota] += i['count']
i['_quotas'] = quotas
else:
i['_quotas'] = []
i['_price'] = price
i['_item'] = item
i['_variation'] = variation
i['_voucher'] = voucher
if err:
raise CartError(err)
return quotadiff
def _check_quota_and_create_positions(event: Event, items: List[dict], cart_id: str, now_dt: datetime,
expiry: datetime, quotadiff: Counter):
"""
This method takes the modified items and the quotadiff from _parse_items_and_check_constraints
and then
* checks that the given quotas are available
* creates as many cart positions as possible
"""
err = None
quotas_ok = {}
cartpositions = []
with event.lock():
for quota, count in quotadiff.items():
avail = quota.availability(now_dt)
if avail[1] is not None and avail[1] < count:
# This quota is not available or less than i['count'] items are left, so we have to
# reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable']
else:
err = err or error_messages['in_part']
quotas_ok[quota] = min(count, avail[1])
else:
quotas_ok[quota] = count
for i in items:
# Create a CartPosition for as much items as we can # Create a CartPosition for as much items as we can
for k in range(quota_ok): requested_count = i['count']
if 'cp' in i and i['count'] == 1: available_count = requested_count
# Recreating if i['_quotas']:
cp = i['cp'] available_count = min(requested_count, min(quotas_ok[q] for q in i['_quotas']))
for q in i['_quotas']:
quotas_ok[q] -= available_count
for k in range(available_count):
if '_cp' in i and i['count'] == 1:
# Recreating an existing position
cp = i['_cp']
cp.expires = expiry cp.expires = expiry
cp.price = price cp.price = i['_price']
cp.save() cp.save()
else: else:
CartPosition.objects.create( cartpositions.append(CartPosition(
event=event, item=item, variation=variation, event=event, item=i['_item'], variation=i['_variation'],
price=price, price=i['_price'],
expires=expiry, expires=expiry,
cart_id=cart_id, voucher=voucher cart_id=cart_id, voucher=i['_voucher']
) ))
return err
CartPosition.objects.bulk_create(cartpositions)
if err:
raise CartError(err)
def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None: def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
with event.lock() as now_dt: now_dt = now()
_check_date(event, now_dt) _check_date(event, now_dt)
existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count() existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count()
if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order): if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order):
# TODO: i18n plurals # TODO: i18n plurals
@@ -212,11 +271,16 @@ def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> No
_extend_existing(event, cart_id, expiry, now_dt) _extend_existing(event, cart_id, expiry, now_dt)
expired = _re_add_expired_positions(items, event, cart_id, now_dt) expired = _re_add_expired_positions(items, event, cart_id, now_dt)
try:
if items: if items:
err = _add_new_items(event, items, cart_id, expiry, now_dt) quotadiff = _parse_items_and_check_constraints(event, items, cart_id, now_dt)
_check_quota_and_create_positions(event, items, cart_id, now_dt, expiry, quotadiff)
except CartError as e:
_delete_expired(expired, now_dt)
raise e
else:
_delete_expired(expired, now_dt) _delete_expired(expired, now_dt)
if err:
raise CartError(err)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)

View File

@@ -1,3 +1,4 @@
import contextlib
import json import json
import logging import logging
from collections import Counter, namedtuple from collections import Counter, namedtuple
@@ -50,6 +51,8 @@ error_messages = {
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'), 'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum ' 'voucher_redeemed': _('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.'), 'number of times allowed. We removed this item from your cart.'),
'voucher_redeemed_partial': _('The voucher code used for one of the items in your cart can only be redeemed %d '
'more times. We removed this item from your cart.'),
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item ' 'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
'from your cart.'), 'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We ' 'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
@@ -82,7 +85,15 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
:param user: The user that performed the change :param user: The user that performed the change
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
with order.event.lock() as now_dt: lock_func = order.event.lock
if order.status == order.STATUS_PENDING and order.expires > now() + timedelta(minutes=10):
# No lock necessary in this case. The 10 minute offset is just to be safe and prevent
# collisions with the cronjob.
@contextlib.contextmanager
def lock_func():
yield now()
with lock_func() as now_dt:
can_be_paid = order._can_be_paid() can_be_paid = order._can_be_paid()
if not force and can_be_paid is not True: if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid) raise Quota.QuotaExceededException(can_be_paid)
@@ -185,6 +196,9 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]): def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
"""
Checks constraints on all positions except quota
"""
err = None err = None
_check_date(event, now_dt) _check_date(event, now_dt)
@@ -193,17 +207,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
cp.delete() cp.delete()
continue continue
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) cp._quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk=cp.pk)
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry!
continue
if cp.item.require_voucher and cp.voucher is None: if cp.item.require_voucher and cp.voucher is None:
cp.delete() cp.delete()
@@ -223,7 +227,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
price = cp.item.default_price if cp.variation is None else ( price = cp.item.default_price if cp.variation is None else (
cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price) cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price)
if price is False or len(quotas) == 0: if price is False or len(cp._quotas) == 0:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
cp.delete() cp.delete()
continue continue
@@ -242,63 +246,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.save() cp.save()
err = err or error_messages['price_changed'] err = err or error_messages['price_changed']
continue 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
avail = quota.availability(now_dt)
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
err = err or error_messages['unavailable']
quota_ok = False
break
if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
cp.delete() # Sorry!
if err: if err:
raise OrderError(err) raise OrderError(err)
@transaction.atomic @transaction.atomic
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: int=None, payment_provider: BasePaymentProvider, expires: datetime, locale: str=None, address: int=None,
meta_info: dict=None): meta_info: dict=None):
from datetime import date, time
total = sum([c.price for c in positions]) total = sum([c.price for c in positions])
payment_fee = payment_provider.calculate_fee(total) payment_fee = payment_provider.calculate_fee(total)
total += payment_fee total += payment_fee
tz = pytz.timezone(event.settings.timezone)
exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
exp_by_date = exp_by_date.replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
expires = exp_by_date
if event.settings.get('payment_term_last'):
last_date = make_aware(datetime.combine(
event.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
if last_date < expires:
expires = last_date
order = Order.objects.create( order = Order.objects.create(
status=Order.STATUS_PENDING, status=Order.STATUS_PENDING,
event=event, event=event,
@@ -330,6 +290,94 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
return order return order
def _check_quota_on_expired_positions(event: Event, positions: List[CartPosition], now_dt: datetime):
err = None
quotadiff = Counter()
vouchers = Counter()
for cp in positions:
if not cp.id:
continue
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 ignore_all_quotas:
cp._quotas = []
elif cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id:
cp._quotas = [q for q in cp._quotas if cp.voucher.quota_id != q.pk]
for quota in cp._quotas:
quotadiff[quota] += 1
quotas_ok = {}
for quota, count in quotadiff.items():
avail = quota.availability(now_dt)
if avail[1] is not None and avail[1] < count:
# This quota is not available or less than items are than requested left, so we have to
# reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable']
else:
err = err or error_messages['in_part']
quotas_ok[quota] = min(count, avail[1])
else:
quotas_ok[quota] = count
for cp in positions:
if not cp.id:
continue
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp2.pk for cp2 in positions])
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry!
continue
if v_avail - vouchers[cp.voucher] < 1:
err = err or (error_messages['voucher_redeemed_partial'] % v_avail)
cp.delete() # Sorry!
continue
vouchers[cp.voucher] += 1
if cp._quotas:
if min(quotas_ok[q] for q in cp._quotas) > 0:
cp.expires = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
for q in cp._quotas:
quotas_ok[q] -= 1
else:
cp.delete()
if err:
raise OrderError(err)
def _calculate_expiry(event: Event, now_dt: datetime):
from datetime import date, time
tz = pytz.timezone(event.settings.timezone)
expires = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
expires = expires.replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if expires.weekday() == 5:
expires += timedelta(days=2)
elif expires.weekday() == 6:
expires += timedelta(days=1)
if event.settings.get('payment_term_last'):
last_date = make_aware(datetime.combine(
event.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
if last_date < expires:
expires = last_date
return expires
def _perform_order(event: str, payment_provider: str, position_ids: List[str], def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None): email: str, locale: str, address: int, meta_info: dict=None):
@@ -343,13 +391,18 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if not pprov: if not pprov:
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
with event.lock() as now_dt: now_dt = now()
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation')) positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation'))
if len(position_ids) != len(positions): if set(str(p) for p in position_ids) != set(str(p.id) for p in positions):
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions) _check_positions(event, now_dt, positions)
order = _create_order(event, email, positions, now_dt, pprov, expires = _calculate_expiry(event, now_dt)
with event.lock() as now_dt:
_check_quota_on_expired_positions(event, positions, now_dt)
order = _create_order(event, email, positions, now_dt, pprov, expires,
locale=locale, address=address, meta_info=meta_info) locale=locale, address=address, meta_info=meta_info)
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -570,12 +623,12 @@ class OrderChangeManager:
# Do nothing # Do nothing
return return
with transaction.atomic(): with transaction.atomic():
self._check_free_to_paid()
self._check_complete_cancel()
with self.order.event.lock(): with self.order.event.lock():
if self.order.status != Order.STATUS_PENDING: if self.order.status != Order.STATUS_PENDING:
raise OrderError(self.error_messages['not_pending']) raise OrderError(self.error_messages['not_pending'])
self._check_free_to_paid()
self._check_quotas() self._check_quotas()
self._check_complete_cancel()
self._perform_operations() self._perform_operations()
self._recalculate_total_and_payment_fee() self._recalculate_total_and_payment_fee()
self._reissue_invoice() self._reissue_invoice()

View File

@@ -7,9 +7,8 @@ from django.utils.timezone import make_aware, now
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.payment import FreeOrderProvider
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, expire_orders, OrderChangeManager, OrderError, _calculate_expiry, expire_orders,
) )
@@ -28,10 +27,7 @@ def test_expiry_days(event):
today = now() today = now()
event.settings.set('payment_term_days', 5) event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', False) event.settings.set('payment_term_weekdays', False)
order = _create_order(event, email='dummy@example.org', positions=[], assert (_calculate_expiry(event, today) - today).days == 5
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 5
@pytest.mark.django_db @pytest.mark.django_db
@@ -39,18 +35,12 @@ def test_expiry_weekdays(event):
today = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0)) today = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0))
event.settings.set('payment_term_days', 5) event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', True) event.settings.set('payment_term_weekdays', True)
order = _create_order(event, email='dummy@example.org', positions=[], assert (_calculate_expiry(event, today) - today).days == 6
now_dt=today, payment_provider=FreeOrderProvider(event), assert _calculate_expiry(event, today).weekday() == 0
locale='de')
assert (order.expires - today).days == 6
assert order.expires.weekday() == 0
today = make_aware(datetime(2016, 9, 19, 15, 0, 0, 0)) today = make_aware(datetime(2016, 9, 19, 15, 0, 0, 0))
order = _create_order(event, email='dummy@example.org', positions=[], assert (_calculate_expiry(event, today) - today).days == 7
now_dt=today, payment_provider=FreeOrderProvider(event), assert _calculate_expiry(event, today).weekday() == 0
locale='de')
assert (order.expires - today).days == 7
assert order.expires.weekday() == 0
@pytest.mark.django_db @pytest.mark.django_db
@@ -59,15 +49,9 @@ def test_expiry_last(event):
event.settings.set('payment_term_days', 5) event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', False) event.settings.set('payment_term_weekdays', False)
event.settings.set('payment_term_last', now() + timedelta(days=3)) event.settings.set('payment_term_last', now() + timedelta(days=3))
order = _create_order(event, email='dummy@example.org', positions=[], assert (_calculate_expiry(event, today) - today).days == 3
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 3
event.settings.set('payment_term_last', now() + timedelta(days=7)) event.settings.set('payment_term_last', now() + timedelta(days=7))
order = _create_order(event, email='dummy@example.org', positions=[], assert (_calculate_expiry(event, today) - today).days == 5
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 5
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -349,6 +349,20 @@ class CartTest(CartTestMixin, TestCase):
self.assertIsNone(objs[0].variation) self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, 23) self.assertEqual(objs[0].price, 23)
def test_quota_partly_multiple_products(self):
self.quota_tickets.size = 1
self.quota_tickets.save()
self.quota_tickets.items.add(self.shirt)
self.quota_tickets.variations.add(self.shirt_red)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1'
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
def test_renew_in_time(self): def test_renew_in_time(self):
cp = CartPosition.objects.create( cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -539,10 +553,10 @@ class CartTest(CartTestMixin, TestCase):
) )
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
'_voucher_code': v.code,
}, follow=True) }, follow=True)
obj = CartPosition.objects.get(id=cp1.id) obj = CartPosition.objects.get(id=cp1.id)
self.assertGreater(obj.expires, now()) self.assertGreater(obj.expires, now())
self.assertEqual(obj.voucher, v)
def test_voucher_variation(self): def test_voucher_variation(self):
v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, event=self.event) v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, event=self.event)
@@ -801,10 +815,9 @@ class CartTest(CartTestMixin, TestCase):
'_voucher_code': v.code, '_voucher_code': v.code,
}, follow=True) }, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('already been used', doc.select('.alert-danger')[0].text) self.assertIn('only be redeemed 1 more time', doc.select('.alert-danger')[0].text)
positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event) positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event)
assert positions.count() == 1 assert positions.count() == 0
assert all(cp.voucher == v for cp in positions)
def test_voucher_multiuse_redeemed(self): def test_voucher_multiuse_redeemed(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
@@ -843,9 +856,9 @@ class CartTest(CartTestMixin, TestCase):
'_voucher_code': v.code, '_voucher_code': v.code,
}, follow=True) }, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('already been used', doc.select('.alert-danger')[0].text) self.assertIn('only be redeemed 1 more time', doc.select('.alert-danger')[0].text)
positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event) positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event)
assert positions.count() == 1 assert positions.count() == 0
def test_voucher_multiuse_redeemed_in_other_cart(self): def test_voucher_multiuse_redeemed_in_other_cart(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,

View File

@@ -405,10 +405,31 @@ class CheckoutTestCase(TestCase):
self._set_session('payment', 'banktransfer') self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn("has already been", doc.select(".alert-danger")[0].text) self.assertIn("only be redeemed 1 more time", doc.select(".alert-danger")[0].text)
assert CartPosition.objects.filter(cart_id=self.session_key).count() == 1 assert CartPosition.objects.filter(cart_id=self.session_key).count() == 1
def test_voucher_multiuse_ok(self): def test_voucher_multiuse_ok(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
valid_until=now() + timedelta(days=2), max_usages=3, redeemed=1)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=12, expires=now() + timedelta(minutes=10), voucher=v
)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=12, expires=now() + timedelta(minutes=10), voucher=v
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 2)
v.refresh_from_db()
assert v.redeemed == 3
def test_voucher_multiuse_ok_expired(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
valid_until=now() + timedelta(days=2), max_usages=3, redeemed=1) valid_until=now() + timedelta(days=2), max_usages=3, redeemed=1)
CartPosition.objects.create( CartPosition.objects.create(
@@ -472,7 +493,7 @@ class CheckoutTestCase(TestCase):
self._set_session('payment', 'banktransfer') self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn("has already been", doc.select(".alert-danger")[0].text) self.assertIn("only be redeemed 1 more time", doc.select(".alert-danger")[0].text)
assert CartPosition.objects.filter(cart_id=self.session_key).count() == 1 assert CartPosition.objects.filter(cart_id=self.session_key).count() == 1
def test_voucher_ignore_quota(self): def test_voucher_ignore_quota(self):

View File

@@ -10,3 +10,7 @@ INSTALLED_APPS.append('tests.testdummy') # NOQA
for a in PLUGINS: for a in PLUGINS:
INSTALLED_APPS.remove(a) INSTALLED_APPS.remove(a)
DATABASES['default'] = {
'ENGINE': 'django.db.backends.sqlite3',
}