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
51 changed files with 1384 additions and 2021 deletions

View File

@@ -201,10 +201,6 @@ You can use an existing memcached server as pretix's caching backend::
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
propagated otherwise.
Redis
-----

View File

@@ -168,7 +168,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
[Install]
WantedBy=multi-user.target
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following comamnds
to enable and start the service::
# systemctl daemon-reload

View File

@@ -5,33 +5,51 @@ General remarks
Requirements
------------
To use pretix, you wull need the following things:
To use pretix, the most minimal setup consists of:
* **pretix** and the python packages it depends on
* An **WSGI application server** (we recommend gunicorn)
* A periodic task runner, e.g. ``cron``
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either
go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful
for evaluation and development purposes.
To run pretix, you will need **at least Python 3.4**. We only recommend installations on **Linux**, Windows is not
officially supported (but might work).
.. warning:: Do not ever use SQLite in production. It will break.
Optional requirements
---------------------
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
faster. Also, you need a proxying web server in front to provide SSL encryption.
pretix is built in a way that makes many of the following requirements optional. However, performance or security might
be very low if you skip some of them, therefore they are only partly optional.
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
Database
A good SQL-based database to run on that is supported by Django. We highly recommend to either go for **PostgreSQL**
or **MySQL/MariaDB**.
If you do not provide one, pretix will run on SQLite, which is useful for evaluation and development purposes.
* A **redis** server. This will be used for caching, session storage and task queuing.
.. warning:: Do not ever use SQLite in production. It will break.
.. warning:: pretix can run without redis, however this is only intended for development and should never be
used in production.
Reverse proxy
pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix is capable of
doing this, having this handled by a proper web server like **nginx** or **Apache** will be much faster. Also, you
need a proxying web server in front to provide SSL encryption.
* Optionally: RabbitMQ or memcached. Both of them might provide speedups, but if they are not present,
redis will take over their job.
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
Task worker
When pretix has to do heavy stuff, it is better to offload it into a background process instead of having the
users connection wait. Therefore pretix provides a background service that can be used to work on those
longer-running tasks.
This requires at least Redis (and optionally RabbitMQ).
Redis
If you provide a redis instance, pretix is able to make use of it in the three following ways:
* Caching
* Fast session storage
* Queuing and result storage for the task worker queue
RabbitMQ
RabbitMQ can be used as a more advanced queue manager for the task workers if necessary.
.. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -70,6 +70,8 @@ The provider class
.. automethod:: is_allowed
.. automethod:: is_allowed_for_order
.. autoattribute:: payment_form_fields
.. automethod:: checkout_prepare

View File

@@ -65,3 +65,5 @@ The output class
.. automethod:: generate
.. autoattribute:: download_button_text
.. autoattribute:: download_button_icon

View File

@@ -6,7 +6,7 @@ localecompile:
localegen:
./manage.py makemessages --all --ignore "pretix/helpers/*"
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*" --ignore "static/jsi18n/*"
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*"
staticfiles: jsi18n
./manage.py collectstatic --noinput

View File

@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.3 on 2016-11-29 13:30
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0047_auto_20161126_1300'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
),
migrations.RenameField(
model_name='voucher',
old_name='price',
new_name='value',
),
migrations.AlterField(
model_name='voucher',
name='value',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
),
]

View File

@@ -8,8 +8,6 @@ import pytz
from django.conf import settings
from django.db import models
from django.db.models import F
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
@@ -281,12 +279,13 @@ class Order(LoggedModel):
if now() > last_date:
return error_messages['late']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late'):
return error_messages['late']
return self._is_still_available()
if self.status == self.STATUS_PENDING:
return True
else:
return self._is_still_available()
def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]:
error_messages = {
@@ -565,9 +564,3 @@ class CachedTicket(models.Model):
order_position = models.ForeignKey(OrderPosition, on_delete=models.CASCADE)
cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE, null=True)
provider = models.CharField(max_length=255)
@receiver(post_delete, sender=CachedTicket)
def cached_file_delete(sender, instance, **kwargs):
if instance.cachedfile:
instance.cachedfile.delete()

View File

@@ -1,5 +1,3 @@
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
@@ -7,7 +5,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event
from .items import Item, ItemVariation, Quota
@@ -43,11 +40,8 @@ class Voucher(LoggedModel):
:type block_quota: bool
:param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out
:type allow_ignore_quota: bool
:param price_mode: Sets how this voucher affects a product's price. Can be ``none``, ``set``, ``subtract``
or ``percent``.
:type price_mode: str
:param value: The value by which the price should be modified in the way specified by ``price_mode``.
:type value: decimal.Decimal
:param price: If set, the voucher will allow the sale of associated items for this price
:type price: decimal.Decimal
:param item: If set, the item to sell
:type item: Item
:param variation: If set, the variation to sell
@@ -65,13 +59,6 @@ class Voucher(LoggedModel):
* You need to either select a quota or an item
* If you select an item that has variations but do not select a variation, you cannot set block_quota
"""
PRICE_MODES = (
('none', _('No effect')),
('set', _('Set product price to')),
('subtract', _('Subtract from product price')),
('percent', _('Reduce product price by (%)')),
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
@@ -111,15 +98,10 @@ class Voucher(LoggedModel):
"If activated, a holder of this voucher code can buy tickets, even if there are none left."
)
)
price_mode = models.CharField(
verbose_name=_("Price mode"),
max_length=100,
choices=PRICE_MODES,
default='set'
)
value = models.DecimalField(
verbose_name=_("Voucher value"),
price = models.DecimalField(
verbose_name=_("Set product price to"),
decimal_places=2, max_digits=10, null=True, blank=True,
help_text=_('If empty, the product will cost its normal price.')
)
item = models.ForeignKey(
Item, related_name='vouchers',
@@ -226,19 +208,3 @@ class Voucher(LoggedModel):
if self.valid_until and self.valid_until < now():
return False
return True
def calculate_price(self, original_price: Decimal) -> Decimal:
"""
Returns how the price given in original_price would be modified if this
voucher is applied, i.e. replaced by a different price or reduced by a
certain percentage. If the voucher does not modify the price, the
original price will be returned.
"""
if self.value:
if self.price_mode == 'set':
return self.value
elif self.price_mode == 'subtract':
return original_price - self.value
elif self.price_mode == 'percent':
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
return original_price

View File

@@ -1,9 +1,11 @@
from collections import Counter
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional
from typing import List
from celery.exceptions import MaxRetriesExceededError
from django.db.models import Q
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from pretix.base.i18n import LazyLocaleException
@@ -35,8 +37,6 @@ error_messages = {
'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_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_invalid_item': _('This voucher is not valid for 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,
'count': 1,
'price': cp.price,
'cp': cp,
'_cp': cp,
'voucher': cp.voucher.code if cp.voucher else None
})
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:
for cp in expired:
if cp.expires <= now_dt:
if cp.expires <= now_dt: # Has not been extended
cp.delete()
@@ -85,8 +85,17 @@ def _check_date(event: Event, now_dt: datetime) -> None:
raise CartError(error_messages['ended'])
def _add_new_items(event: Event, items: List[dict],
cart_id: str, expiry: datetime, now_dt: datetime) -> Optional[str]:
def _parse_items_and_check_constraints(event: Event, items: List[dict], cart_id: 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
# 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")
variations_cache = {v.id: v for v in variations_query}
quotadiff = Counter()
vouchers = Counter()
for i in items:
# 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
@@ -116,106 +128,159 @@ def _add_new_items(event: Event, items: List[dict],
try:
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
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:
return error_messages['voucher_expired']
raise CartError(error_messages['voucher_expired'])
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(
Q(voucher=voucher) & Q(event=event) &
(Q(expires__gte=now_dt) | Q(cart_id=cart_id))
Q(voucher=voucher) & Q(event=event) & Q(expires__gte=now_dt)
)
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()
if v_avail < 1:
return error_messages['voucher_redeemed']
if i['count'] > v_avail:
return error_messages['voucher_redeemed_partial'] % v_avail
raise CartError(error_messages['voucher_redeemed'])
if i['count'] > v_avail - vouchers[voucher]:
raise CartError(error_messages['voucher_redeemed_partial'] % v_avail)
vouchers[voucher] += i['count']
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.
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]:
return error_messages['voucher_invalid_item']
raise CartError(error_messages['voucher_invalid_item'])
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):
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):
err = err or error_messages['unavailable']
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])
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if voucher:
price = voucher.calculate_price(price)
if voucher and voucher.price is not None:
price = voucher.price
else:
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "":
custom_price = i['price']
if not isinstance(custom_price, Decimal):
custom_price = Decimal(custom_price.replace(",", "."))
if custom_price > 100000000:
return error_messages['price_too_high']
raise CartError(error_messages['price_too_high'])
price = max(custom_price, price)
# Create a CartPosition for as much items as we can
for k in range(quota_ok):
if 'cp' in i and i['count'] == 1:
# Recreating
cp = i['cp']
cp.expires = expiry
cp.price = price
cp.save()
# 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:
CartPosition.objects.create(
event=event, item=item, variation=variation,
price=price,
expires=expiry,
cart_id=cart_id, voucher=voucher
)
return err
quotas_ok[quota] = count
for i in items:
# Create a CartPosition for as much items as we can
requested_count = i['count']
available_count = requested_count
if i['_quotas']:
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.price = i['_price']
cp.save()
else:
cartpositions.append(CartPosition(
event=event, item=i['_item'], variation=i['_variation'],
price=i['_price'],
expires=expiry,
cart_id=cart_id, voucher=i['_voucher']
))
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:
with event.lock() as now_dt:
_check_date(event, now_dt)
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):
# TODO: i18n plurals
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,))
now_dt = now()
_check_date(event, now_dt)
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
_extend_existing(event, cart_id, expiry, now_dt)
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):
# TODO: i18n plurals
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,))
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
_extend_existing(event, cart_id, expiry, now_dt)
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
try:
if items:
err = _add_new_items(event, items, cart_id, expiry, now_dt)
_delete_expired(expired, now_dt)
if err:
raise CartError(err)
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)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)

View File

@@ -82,15 +82,11 @@ def mail(email: str, subject: str, template: str,
if order:
body += "\r\n"
body += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}
)
)
"You can view your order details at the following URL:\r\n{orderurl}."
).format(event=event.name, orderurl=build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}))
body += "\r\n"
return mail_send([email], subject, body, sender, event.id if event else None, headers)

View File

@@ -1,3 +1,4 @@
import contextlib
import json
import logging
from collections import Counter, namedtuple
@@ -21,7 +22,7 @@ from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User, Voucher,
)
from pretix.base.models.orders import CachedTicket, InvoiceAddress
from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import BasePaymentProvider
from pretix.base.services.async import ProfiledTask
from pretix.base.services.invoices import (
@@ -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_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.'),
'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 '
'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 '
@@ -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
: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()
if not force and can_be_paid is not True:
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]):
"""
Checks constraints on all positions except quota
"""
err = None
_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']
cp.delete()
continue
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
cp._quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.item.require_voucher and cp.voucher is None:
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 (
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']
cp.delete()
continue
@@ -233,7 +237,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['voucher_expired']
cp.delete()
continue
price = cp.voucher.calculate_price(price)
if cp.voucher.price is not None:
price = cp.voucher.price
if price != cp.price and not (cp.item.free_price and cp.price > price):
positions[i] = cp
@@ -241,63 +246,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.save()
err = err or error_messages['price_changed']
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:
raise OrderError(err)
@transaction.atomic
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):
from datetime import date, time
total = sum([c.price for c in positions])
payment_fee = payment_provider.calculate_fee(total)
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.astimezone(tz).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(
status=Order.STATUS_PENDING,
event=event,
@@ -329,6 +290,94 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
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],
email: str, locale: str, address: int, meta_info: dict=None):
@@ -342,13 +391,18 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if not pprov:
raise OrderError(error_messages['internal'])
now_dt = now()
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation'))
if set(str(p) for p in position_ids) != set(str(p.id) for p in positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
expires = _calculate_expiry(event, now_dt)
with event.lock() as now_dt:
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation'))
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
order = _create_order(event, email, positions, now_dt, pprov,
_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)
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -569,22 +623,18 @@ class OrderChangeManager:
# Do nothing
return
with transaction.atomic():
self._check_free_to_paid()
self._check_complete_cancel()
with self.order.event.lock():
if self.order.status != Order.STATUS_PENDING:
raise OrderError(self.error_messages['not_pending'])
self._check_free_to_paid()
self._check_quotas()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
self._reissue_invoice()
self._clear_tickets_cache()
self._check_paid_to_free()
self._notify_user()
def _clear_tickets_cache(self):
CachedTicket.objects.filter(order_position__order=self.order).delete()
def _get_payment_provider(self):
responses = register_payment_providers.send(self.order.event)
pprov = None

View File

@@ -105,12 +105,6 @@ class OrderPositionChangeForm(forms.Form):
class OrderContactForm(forms.ModelForm):
regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'),
help_text=_('Regenerates the order and ticket secrets. You will '
'need to re-send the link to the order page to the user and '
'the user will need to download his tickets again. The old '
'versions will be invalid.'))
class Meta:
model = Order
fields = ['email']

View File

@@ -8,7 +8,6 @@ from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import I18nModelForm
from pretix.base.models import Item, ItemVariation, Quota, Voucher
from pretix.base.models.vouchers import _generate_random_code
class VoucherForm(I18nModelForm):
@@ -23,8 +22,8 @@ class VoucherForm(I18nModelForm):
model = Voucher
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode'
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag',
'comment', 'max_usages'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -91,8 +90,9 @@ class VoucherForm(I18nModelForm):
}
)
if 'number' in data:
cnt = data['number'] * data['max_usages']
if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
cnt = len(data['codes']) * data['max_usages']
else:
cnt = data['max_usages']
@@ -174,37 +174,21 @@ class VoucherForm(I18nModelForm):
class VoucherBulkForm(VoucherForm):
number = forms.IntegerField(
label=_("Number"),
codes = forms.CharField(
widget=forms.Textarea,
label=_("Codes"),
help_text=_(
"Add one voucher code per line. We suggest that you copy this list and save it into a file."
),
required=True
)
itemvar = forms.ChoiceField(
label=_("Product"),
widget=forms.RadioSelect
)
price_mode = forms.ChoiceField(
choices=Voucher.PRICE_MODES,
)
has_valid_until = forms.BooleanField()
value_percent = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2
)
value_subtract = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2
)
value_set = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2
)
class Meta:
model = Voucher
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode'
'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment',
'max_usages'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -219,35 +203,21 @@ class VoucherBulkForm(VoucherForm):
def clean(self):
data = super().clean()
if data.get('has_valid_until', False) and not data.get('valid_until'):
raise ValidationError(_('You did not specify an expiration date for the vouchers.'))
if data.get('price_mode', 'none') != 'none':
if data.get('value_%s' % data['price_mode']) is None:
raise ValidationError(_('You specified that the vouchers should modify the products price '
'but did not specify a value.'))
if Voucher.objects.filter(code__in=data['codes'], event=self.instance.event).exists():
raise ValidationError(_('A voucher with one of this codes already exists.'))
return data
def save(self, event, *args, **kwargs):
objs = []
codes = set()
while len(codes) < self.cleaned_data['number']:
new_codes = set()
for i in range(min(self.cleaned_data['number'] - len(codes), 500)):
# Work around SQLite's SQLITE_MAX_VARIABLE_NUMBER
new_codes.add(_generate_random_code())
new_codes -= set([v['code'] for v in Voucher.objects.filter(code__in=new_codes).values('code')])
codes |= new_codes
for code in codes:
for code in self.cleaned_data['codes']:
obj = copy.copy(self.instance)
obj.event = event
obj.code = code
data = dict(self.cleaned_data)
data['code'] = code
data['bulk'] = True
del data['codes']
obj.save()
objs.append(obj)
return objs

View File

@@ -29,7 +29,6 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/voucher.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}

View File

@@ -99,8 +99,8 @@
<th>{{ total.num_canceled|togglesum }}</th>
<th>{{ total.num_refunded|togglesum }}</th>
<th>{{ total.num_expired|togglesum }}</th>
<th>{{ total.num_pending|togglesum }}</th>
<th>{{ total.num_paid|togglesum }}</th>
<th>{{ total.num_pending|togglesum }}</th>
<th>{{ total.num_total|togglesum }}</th>
</tr>
</tfoot>

View File

@@ -2,139 +2,59 @@
{% load i18n %}
{% load eventsignal %}
{% load bootstrap3 %}
{% load capture_tags %}
{% block title %}{% trans "Voucher" %}{% endblock %}
{% block inside %}
<h1>{% trans "Create new vouchers" %}</h1>
<form action="" method="post" class="form-inline" id="voucher-create">
<h1>{% trans "Create multiple voucher" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% capture as number_field silent %}&nbsp;{% bootstrap_field form.number layout="inline" %}&nbsp;{% endcapture %}
{% capture as max_usages_field silent %}&nbsp;{% bootstrap_field form.max_usages layout="inline" %}&nbsp;{% endcapture %}
{% capture as valid_until_field silent %}&nbsp;{% bootstrap_field form.valid_until layout="inline" %}&nbsp;{% endcapture %}
{% capture as value_field_percent silent %}&nbsp;{% bootstrap_field form.value_percent layout="inline" %}&nbsp;{% endcapture %}
{% capture as value_field_subtract silent %}&nbsp;{% bootstrap_field form.value_subtract layout="inline" %}&nbsp;{% endcapture %}
{% capture as value_field_set silent %}&nbsp;{% bootstrap_field form.value_set layout="inline" %}&nbsp;{% endcapture %}
{% bootstrap_form_errors form %}
<div class="wizard-step" id="step-number">
<div class="wizard-step-inner">
<h4>{% trans "How many vouchers do you want to create?" %}</h4>
<div class="form-line">
{% blocktrans trimmed %}
Create {{ number_field }} voucher codes. Each of them can be redeemed {{ max_usages_field }}
times.
{% endblocktrans %}
</div>
</div>
</div>
<div class="wizard-step" id="step-valid">
<div class="wizard-step-inner">
<h4>{% trans "How long should the vouchers be valid?" %}</h4>
<div class="radio radio-alt">
<label>
<input type="radio" name="has_valid_until" value="" {% if request.POST and not "has_valid_until" in request.POST %}checked="checked"{% endif %}>
{% trans "The whole presale period" %}
</label>
</div>
<div class="radio radio-alt">
<label>
<input type="radio" name="has_valid_until" value="on" {% if "on" == request.POST.has_valid_until %}checked="checked"{% endif %}>
{% blocktrans trimmed %}
Only valid until {{ valid_until_field }}
{% endblocktrans %}
</label>
</div>
</div>
</div>
<div class="wizard-step" id="step-products">
<div class="wizard-step-inner">
<h4>{% trans "For which products should the vouchers be applicable?" %}</h4>
{% bootstrap_field form.itemvar layout="inline" %}
</div>
</div>
<div class="wizard-step" id="step-price">
<div class="wizard-step-inner">
<h4>{% trans "Should the vouchers modify the product's price?" %}</h4>
<div class="radio radio-alt">
<label>
<input type="radio" name="price_mode" value="none" {% if "none" == request.POST.price_mode %}checked="checked"{% endif %}>
{% trans "No, just allow to buy this product" %}
<span class="help-block">
{% trans "This is useful if you have products that can only be bought using vouchers. It also allows you to just block quota for someone (see next step)." %}
</span>
</label>
</div>
<div class="radio radio-alt">
<label>
<input type="radio" name="price_mode" value="percent" {% if "percent" == request.POST.price_mode %}checked="checked"{% endif %}>
{% blocktrans trimmed %}
Reduce price by {{ value_field_percent }} %
{% endblocktrans %}
</label>
</div>
<div class="radio radio-alt">
<label>
<input type="radio" name="price_mode" value="subtract" {% if "subtract" == request.POST.price_mode %}checked="checked"{% endif %}>
{% blocktrans trimmed with currency=request.event.currency %}
Reduce price by {{ value_field_subtract }} {{ currency }}
{% endblocktrans %}
</label>
</div>
<div class="radio radio-alt">
<label>
<input type="radio" name="price_mode" value="set" {% if "set" == request.POST.price_mode %}checked="checked"{% endif %}>
{% blocktrans trimmed with currency=request.event.currency %}
Change price to {{ value_field_set }} {{ currency }}
{% endblocktrans %}
</label>
</div>
</div>
</div>
<div class="wizard-step" id="step-block">
<div class="wizard-step-inner">
<h4>{% trans "Should the vouchers block quota?" %}</h4>
<div class="radio radio-alt">
<label>
<input type="radio" name="block_quota" value="" {% if request.POST and not "block_quota" in request.POST %}checked="checked"{% endif %}>
{% trans "No" %}
</label>
</div>
<div class="radio radio-alt">
<label>
<input type="radio" name="block_quota" value="on" {% if "on" == request.POST.block_quota %}checked="checked"{% endif %}>
{% trans "Yes" %}
<span class="help-block">
{% trans "If you select this option, these vouchers will be guaranteed, i.e. the applicable quotas will be reduced in a way that these vouchers can be redeemed as long as they are valid, even if your event sells out otherwise." %}
</span>
</label>
</div>
</div>
</div>
<div class="wizard-step" id="step-advanced">
<div class="wizard-step-inner">
<a href="#" id="wizard-advanced-show">Show advanced options</a>
<div class="wizard-advanced">
<h4>{% trans "Advanced options" %}</h4>
{% bootstrap_field form.allow_ignore_quota %}
<p><strong>{% trans "Comment" %}</strong></p>
{% bootstrap_field form.comment layout="inline" form_group_class="comment" %}
<div class="help-block">
{% trans "The text entered in this field will not be visible to the user and is available for your convenience." %}
<fieldset>
<legend>{% trans "Voucher codes" %}</legend>
<div class="form-group">
<div class="col-md-6 col-md-offset-3">
<div class="input-group">
<input type="text" class="form-control input-xs"
id="voucher-bulk-codes-num"
placeholder="{% trans "Number" %}">
<div class="input-group-btn">
<button class="btn btn-default" type="button" id="voucher-bulk-codes-generate"
data-rng-url="{% url 'control:event.vouchers.rng' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Generate random codes" %}
</button>
</div>
</div>
</div>
</div>
</div>
{% bootstrap_field form.codes layout="horizontal" %}
{% bootstrap_field form.max_usages layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}
{% bootstrap_field form.price layout="horizontal" %}
{% bootstrap_field form.itemvar layout="horizontal" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
<div class="alert alert-info">
{% blocktrans trimmed %}
If you choose "any product" for a specific quota and choose to reserve quota for this
voucher above, the product can still be unavailable to the voucher holder if another quota
associated with the product is sold out!
{% endblocktrans %}
</div>
</div>
</div>
</div>
{% bootstrap_field form.tag layout="horizontal" %}
{% bootstrap_field form.comment layout="horizontal" %}
</fieldset>
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}
<div class="submit-group" id="step-save">
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Create" %}
{% trans "Save" %}
</button>
</div>
</form>

View File

@@ -27,15 +27,7 @@
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.price layout="horizontal" %}
{% bootstrap_field form.itemvar layout="horizontal" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">

View File

@@ -37,12 +37,17 @@
</p>
<a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create new vouchers" %}</a>
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create new vouchers" %}</a>
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i>
{% trans "Create multiple new vouchers" %}</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">

View File

@@ -90,6 +90,7 @@ urlpatterns = [
url(r'^vouchers/(?P<voucher>\d+)/delete$', vouchers.VoucherDelete.as_view(),
name='event.voucher.delete'),
url(r'^vouchers/add$', vouchers.VoucherCreate.as_view(), name='event.vouchers.add'),
url(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
url(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
name='event.order.transition'),
url(r'^orders/(?P<code>[0-9A-Z]+)/resend$', orders.OrderResendLink.as_view(),

View File

@@ -16,7 +16,7 @@ from django.views.generic.detail import SingleObjectMixin
from pretix.base.forms import I18nModelForm
from pretix.base.models import (
CachedTicket, Event, EventPermission, Item, ItemVariation, User,
Event, EventPermission, Item, ItemVariation, User,
)
from pretix.base.services import tickets
from pretix.base.services.invoices import build_preview_invoice_pdf
@@ -450,9 +450,6 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
for k in provider.form.changed_data
}
)
CachedTicket.objects.filter(
order_position__order__event=self.request.event, provider=provider.identifier
).delete()
else:
success = False
form = self.get_form(self.get_form_class())

View File

@@ -18,8 +18,8 @@ from django.views.generic.edit import DeleteView
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CachedTicket, Item, ItemCategory, ItemVariation, Order, Question,
QuestionAnswer, QuestionOption, Quota,
Item, ItemCategory, ItemVariation, Order, Question, QuestionAnswer,
QuestionOption, Quota,
)
from pretix.control.forms.item import (
CategoryForm, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
@@ -787,7 +787,6 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
for k in form.changed_data
}
)
CachedTicket.objects.filter(order_position__item=self.item).delete()
return super().form_valid(form)

View File

@@ -13,8 +13,7 @@ from django.views.generic import DetailView, ListView, TemplateView, View
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CachedTicket, Invoice, Item, ItemVariation, Order, Quota,
generate_position_secret, generate_secret,
CachedFile, Invoice, Item, ItemVariation, Order, Quota,
)
from pretix.base.services.export import export
from pretix.base.services.invoices import (
@@ -529,16 +528,8 @@ class OrderContactChange(OrderView):
if self.form.is_valid():
self.order.log_action('pretix.event.order.contact.changed', {
'old_email': self.order.email,
'new_email': self.form.cleaned_data['email'],
'regenerate_secrets': self.form.cleaned_data['regenerate_secrets']
'new_email': self.form.cleaned_data['email']
})
if self.form.cleaned_data['regenerate_secrets']:
self.order.secret = generate_secret()
for op in self.order.positions.all():
op.secret = generate_position_secret()
op.save()
CachedTicket.objects.filter(order_position__order=self.order).delete()
self.form.save()
messages.success(self.request, _('The order has been changed.'))
return redirect(self.get_order_url())

View File

@@ -59,7 +59,7 @@ class VoucherList(EventPermissionRequiredMixin, ListView):
headers = [
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
_('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages')
_('Price'), _('Tag'), _('Redeemed'), _('Maximum usages')
]
writer.writerow(headers)
@@ -77,8 +77,7 @@ class VoucherList(EventPermissionRequiredMixin, ListView):
prod,
_("Yes") if v.block_quota else _("No"),
_("Yes") if v.allow_ignore_quota else _("No"),
v.get_price_mode_display(),
str(v.value) if v.value else "",
str(v.price) if v.price else "",
v.tag,
str(v.redeemed),
str(v.max_usages)
@@ -189,6 +188,44 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView):
class VoucherCreate(EventPermissionRequiredMixin, CreateView):
model = Voucher
template_name = 'pretixcontrol/vouchers/detail.html'
permission = 'can_change_vouchers'
context_object_name = 'voucher'
def get_form_class(self):
form_class = VoucherForm
for receiver, response in voucher_form_class.send(self.request.event, cls=form_class):
if response:
form_class = response
return form_class
def get_success_url(self) -> str:
return reverse('control:event.vouchers', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = Voucher(event=self.request.event)
return kwargs
@transaction.atomic
def form_valid(self, form):
form.instance.event = self.request.event
messages.success(self.request, _('The new voucher has been created.'))
ret = super().form_valid(form)
form.instance.log_action('pretix.voucher.added', data=dict(form.cleaned_data), user=self.request.user)
return ret
def post(self, request, *args, **kwargs):
# TODO: Transform this into an asynchronous call?
with request.event.lock():
return super().post(request, *args, **kwargs)
class VoucherBulkCreate(EventPermissionRequiredMixin, CreateView):
model = Voucher
template_name = 'pretixcontrol/vouchers/bulk.html'
permission = 'can_change_vouchers'
@@ -203,11 +240,6 @@ class VoucherCreate(EventPermissionRequiredMixin, CreateView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = Voucher(event=self.request.event)
initial = {
}
if 'initial' in kwargs:
initial.update(kwargs['initial'])
kwargs['initial'] = initial
return kwargs
@transaction.atomic

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-11-27 18:45+0000\n"
"PO-Revision-Date: 2016-11-27 19:46+0100\n"
"POT-Creation-Date: 2016-11-08 19:21+0000\n"
"PO-Revision-Date: 2016-11-08 20:23+0100\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.8.11\n"
"X-Generator: Poedit 1.8.9\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:53
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:59
@@ -46,7 +46,7 @@ msgstr "Gesamtumsatz"
msgid "Contacting Stripe …"
msgstr "Kontaktiere Stripe …"
#: static/pretixcontrol/js/ui/main.js:28 static/pretixpresale/js/ui/main.js:99
#: static/pretixcontrol/js/ui/main.js:28 static/pretixpresale/js/ui/main.js:57
msgid "Close message"
msgstr "Schließen"
@@ -63,22 +63,17 @@ msgid "Count"
msgstr "Anzahl"
#: static/pretixpresale/js/ui/asynctask.js:27
#: static/pretixpresale/js/ui/asynctask.js:62
msgid ""
"Your request has been queued on the server and will now be processed. If "
"this takes longer than two minutes, please contact us or go back in your "
"browser and try again."
msgid "Your request has been queued on the server and will now be processed."
msgstr ""
"Ihre Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
"dies länger als zwei Minuten dauert, kontaktieren Sie uns bitte oder gehen "
"Sie in Ihrem Browser einen Schritt zurück und versuchen es erneut."
"Ihre Anfrage befindet sich beim Server in der Warteschlange und wird nun "
"verarbeitet."
#: static/pretixpresale/js/ui/asynctask.js:41
#: static/pretixpresale/js/ui/asynctask.js:83
#: static/pretixpresale/js/ui/asynctask.js:40
#: static/pretixpresale/js/ui/asynctask.js:74
msgid "An error of type {code} occured."
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:44
#: static/pretixpresale/js/ui/asynctask.js:43
msgid ""
"We currenctly cannot reach the server, but we keep trying. Last error code: "
"{code}"
@@ -86,31 +81,17 @@ msgstr ""
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
"Letzter Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:74
msgid "The request took to long. Please try again."
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
#: static/pretixpresale/js/ui/asynctask.js:86
#: static/pretixpresale/js/ui/asynctask.js:77
msgid ""
"We currenctly cannot reach the server. Please try again. Error code: {code}"
msgstr ""
"Wir können den Server aktuell nicht erreichen. Bitte versuchen Sie es noch "
"einmal. Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:101
#: static/pretixpresale/js/ui/asynctask.js:92
msgid "We are processing your request …"
msgstr "Wir verarbeiten deine Anfrage …"
#: static/pretixpresale/js/ui/asynctask.js:102
msgid ""
"We are currently sending your request to the server. If this takes longer "
"than one minute, please check your internet connection and then reload this "
"page and try again."
msgstr ""
"Ihre Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
"dauert, prüfen Sie bitte Ihre Internetverbindung. Danach können Sie diese "
"Seite neu laden und es erneut versuchen."
#: static/pretixpresale/js/ui/cart.js:10
msgid "The items in your cart are no longer reserved for you."
msgstr "Die Produkte in Ihrem Warenkorb sind nicht mehr für Sie reserviert."
@@ -122,9 +103,3 @@ msgstr[0] ""
"Die Produkte in Ihrem Warenkorb sind noch eine Minute für Sie reserviert."
msgstr[1] ""
"Die Produkte in Ihrem Warenkorb sind noch {num} Minuten für Sie reserviert."
#~ msgid ""
#~ "Your request has been queued on the server and will now be processed."
#~ msgstr ""
#~ "Ihre Anfrage befindet sich beim Server in der Warteschlange und wird nun "
#~ "verarbeitet."

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-11-27 18:45+0000\n"
"PO-Revision-Date: 2016-11-27 19:52+0100\n"
"POT-Creation-Date: 2016-11-08 19:21+0000\n"
"PO-Revision-Date: 2016-11-08 20:22+0100\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 1.8.11\n"
"X-Generator: Poedit 1.8.9\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:53
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:59
@@ -46,7 +46,7 @@ msgstr "Gesamtumsatz"
msgid "Contacting Stripe …"
msgstr "Kontaktiere Stripe …"
#: static/pretixcontrol/js/ui/main.js:28 static/pretixpresale/js/ui/main.js:99
#: static/pretixcontrol/js/ui/main.js:28 static/pretixpresale/js/ui/main.js:57
msgid "Close message"
msgstr "Schließen"
@@ -63,22 +63,17 @@ msgid "Count"
msgstr "Anzahl"
#: static/pretixpresale/js/ui/asynctask.js:27
#: static/pretixpresale/js/ui/asynctask.js:62
msgid ""
"Your request has been queued on the server and will now be processed. If "
"this takes longer than two minutes, please contact us or go back in your "
"browser and try again."
msgid "Your request has been queued on the server and will now be processed."
msgstr ""
"Deine Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
"dies länger als zwei Minuten dauert, kontaktiere uns bitte oder gehe in "
"deinem Browser einen Schritt zurück und versuche es erneut."
"Deine Anfrage befindet sich beim Server in der Warteschlange und wird nun "
"verarbeitet."
#: static/pretixpresale/js/ui/asynctask.js:41
#: static/pretixpresale/js/ui/asynctask.js:83
#: static/pretixpresale/js/ui/asynctask.js:40
#: static/pretixpresale/js/ui/asynctask.js:74
msgid "An error of type {code} occured."
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:44
#: static/pretixpresale/js/ui/asynctask.js:43
msgid ""
"We currenctly cannot reach the server, but we keep trying. Last error code: "
"{code}"
@@ -86,31 +81,17 @@ msgstr ""
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
"Letzter Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:74
msgid "The request took to long. Please try again."
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
#: static/pretixpresale/js/ui/asynctask.js:86
#: static/pretixpresale/js/ui/asynctask.js:77
msgid ""
"We currenctly cannot reach the server. Please try again. Error code: {code}"
msgstr ""
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
"einmal. Fehlercode: {code}"
#: static/pretixpresale/js/ui/asynctask.js:101
#: static/pretixpresale/js/ui/asynctask.js:92
msgid "We are processing your request …"
msgstr "Wir verarbeiten deine Anfrage …"
#: static/pretixpresale/js/ui/asynctask.js:102
msgid ""
"We are currently sending your request to the server. If this takes longer "
"than one minute, please check your internet connection and then reload this "
"page and try again."
msgstr ""
"Deine Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
"dauert, prüfe bitte deine Internetverbindung. Danach kannst du diese Seite "
"neu laden und es erneut versuchen."
#: static/pretixpresale/js/ui/cart.js:10
msgid "The items in your cart are no longer reserved for you."
msgstr "Die Produkte in deinem Warenkorb sind nicht mehr für dich reserviert."
@@ -122,9 +103,3 @@ msgstr[0] ""
"Die Produkte in deinem Warenkorb sind noch eine Minute für dich reserviert."
msgstr[1] ""
"Die Produkte in deinem Warenkorb sind noch {num} Minuten für dich reserviert."
#~ msgid ""
#~ "Your request has been queued on the server and will now be processed."
#~ msgstr ""
#~ "Deine Anfrage befindet sich beim Server in der Warteschlange und wird nun "
#~ "verarbeitet."

View File

@@ -64,6 +64,23 @@ class Paypal(BasePaymentProvider):
def checkout_prepare(self, request, cart):
self.init_api()
items = []
for cp in cart['positions']:
items.append({
"name": str(cp.item.name),
"description": str(cp.variation) if cp.variation else "",
"quantity": cp.count,
"price": str(cp.price),
"currency": request.event.currency
})
if cart['payment_fee']:
items.append({
"name": __('Payment method fee'),
"description": "",
"quantity": 1,
"currency": request.event.currency,
"price": str(cart['payment_fee'])
})
payment = paypalrestsdk.Payment({
'intent': 'sale',
'payer': {
@@ -76,20 +93,13 @@ class Paypal(BasePaymentProvider):
"transactions": [
{
"item_list": {
"items": [
{
"name": __('Order for %s') % str(request.event),
"quantity": 1,
"price": str(cart['total']),
"currency": request.event.currency
}
]
"items": items
},
"amount": {
"currency": request.event.currency,
"total": str(cart['total'])
},
"description": __('Event tickets for {event}').format(event=request.event.name)
"description": __('Event tickets for %s') % request.event.name
}
]
})
@@ -104,6 +114,7 @@ class Paypal(BasePaymentProvider):
logger.error('Invalid payment state: ' + str(payment))
return
request.session['payment_paypal_id'] = payment.id
request.session['payment_paypal_event'] = self.event.id
for link in payment.links:
if link.method == "REDIRECT" and link.rel == "approval_url":
return str(link.href)
@@ -153,30 +164,6 @@ class Paypal(BasePaymentProvider):
return self._execute_payment(payment, request, order)
def _execute_payment(self, payment, request, order):
payment.replace([
{
"op": "replace",
"path": "/transactions/0/item_list",
"value": {
"items": [
{
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
"quantity": 1,
"price": str(order.total),
"currency": order.event.currency
}
]
}
},
{
"op": "replace",
"path": "/transactions/0/description",
"value": __('Order {order} for {event}').format(
event=request.event.name,
order=order.code
)
}
])
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
if payment.state == 'pending':
@@ -275,7 +262,7 @@ class Paypal(BasePaymentProvider):
"item_list": {
"items": [
{
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
"name": 'Order %s' % order.code,
"quantity": 1,
"price": str(order.total),
"currency": order.event.currency
@@ -286,10 +273,7 @@ class Paypal(BasePaymentProvider):
"currency": request.event.currency,
"total": str(order.total)
},
"description": __('Order {order} for {event}').format(
event=request.event.name,
order=order.code
)
"description": __('Event tickets for %s') % request.event.name
}
]
})

View File

@@ -4,48 +4,41 @@ from django.contrib import messages
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Order
from pretix.base.models import Event, Order
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.payment import Paypal
from pretix.presale.utils import event_view
logger = logging.getLogger('pretix.plugins.paypal')
@event_view(require_live=False)
def success(request, *args, **kwargs):
def success(request, organizer=None, event=None):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
if request.session.get('payment_paypal_order'):
order = Order.objects.get(pk=request.session.get('payment_paypal_order'))
else:
order = None
if pid == request.session.get('payment_paypal_id', None):
if order:
prov = Paypal(request.event)
resp = prov.payment_perform(request, order)
if resp:
return resp
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
try:
event = Event.objects.get(id=request.session['payment_paypal_event'])
if request.session.get('payment_paypal_order'):
prov = Paypal(event)
order = Order.objects.get(pk=request.session.get('payment_paypal_order'))
resp = prov.payment_perform(request, order)
return redirect(resp or eventreverse(event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}) + '?paid=yes')
return redirect(eventreverse(event, 'presale:event.checkout', kwargs={'step': 'confirm'}))
except Event.DoesNotExist:
pass # TODO: Handle this
else:
messages.error(request, _('Invalid response from PayPal received.'))
logger.error('Session did not contain payment_paypal_id')
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
if order:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}) + ('?paid=yes' if order.status == Order.STATUS_PAID else ''))
else:
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'confirm'}))
pass # TODO: Handle this
@event_view(require_live=False)
def abort(request, *args, **kwargs):
def abort(request, organizer=None, event=None):
messages.error(request, _('It looks like you canceled the PayPal payment'))
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
try:
event = Event.objects.get(id=request.session['payment_paypal_event'])
return redirect(eventreverse(event, 'presale:event.checkout', kwargs={'step': 'payment'}))
except Event.DoesNotExist:
pass # TODO: Handle this

View File

@@ -219,8 +219,8 @@ class OverviewReport(Report):
str(total['num_canceled'][0]), str(total['num_canceled'][1]),
str(total['num_refunded'][0]), str(total['num_refunded'][1]),
str(total['num_expired'][0]), str(total['num_expired'][1]),
str(total['num_pending'][0]), str(total['num_pending'][1]),
str(total['num_paid'][0]), str(total['num_paid'][1]),
str(total['num_pending'][0]), str(total['num_pending'][1]),
str(total['num_total'][0]), str(total['num_total'][1]),
])

View File

@@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from pretix.base.models import Order
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
from pretix.base.services.orders import mark_order_refunded
from pretix.plugins.stripe.payment import Stripe
from pretix.presale.utils import event_view
@@ -54,10 +54,7 @@ def webhook(request, *args, **kwargs):
order.log_action('pretix.plugins.stripe.event', data=event_json)
is_refund = charge['refunds']['total_count'] or charge['dispute']
if order.status == Order.STATUS_PAID and is_refund:
if order.status == Order.STATUS_PAID and (charge['refunds']['total_count'] or charge['dispute']):
mark_order_refunded(order, user=None)
elif order.status == Order.STATUS_PENDING and charge['status'] == 'succeeded' and not is_refund:
mark_order_paid(order, user=None)
return HttpResponse(status=200)

View File

@@ -198,16 +198,20 @@ class RedeemView(EventViewMixin, TemplateView):
item.cached_availability = (Quota.AVAILABILITY_OK, 1)
else:
item.cached_availability = item.check_quotas()
item.price = self.voucher.calculate_price(item.default_price)
if self.voucher.price is not None:
item.price = self.voucher.price
else:
item.price = item.default_price
else:
for var in item.available_variations:
if self.voucher.allow_ignore_quota or self.voucher.block_quota:
var.cached_availability = (Quota.AVAILABILITY_OK, 1)
else:
var.cached_availability = list(var.check_quotas())
var.price = self.voucher.calculate_price(
var.default_price if var.default_price is not None else item.default_price
)
if self.voucher.price is not None:
var.price = self.voucher.price
else:
var.price = var.default_price if var.default_price is not None else item.default_price
if len(item.available_variations) > 0:
item.min_price = min([v.price for v in item.available_variations])

View File

@@ -401,8 +401,6 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
success_message = ('Your invoice address has been updated. Please contact us if you need us '
'to regenerate your invoice.')
messages.success(self.request, _(success_message))
CachedTicket.objects.filter(order_position__order=self.order).delete()
return redirect(self.get_order_url())
def get(self, request, *args, **kwargs):

View File

@@ -96,7 +96,8 @@ METRICS_PASSPHRASE = config.get('metrics', 'passphrase', fallback="")
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake',
}
}
REAL_CACHE_USED = False
@@ -193,7 +194,6 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_totp',
'django_otp.plugins.otp_static',
'statici18n',
'capture_tag',
]
try:

View File

@@ -1,2 +1,2 @@
paypalrestsdk==1.12.*
paypalrestsdk>=1.9,<1.10,<2.0
pycparser==2.13 # https://github.com/eliben/pycparser/issues/147

View File

@@ -17,7 +17,6 @@ python-u2flib-server==4.*
# https://github.com/celery/celery/pull/3199
git+https://github.com/pretix/celery.git@pretix#egg=celery
django-statici18n==1.2.*
django-capture-tag==1.0
# Deployment / static file compilation requirements
BeautifulSoup4

View File

@@ -62,8 +62,7 @@ setup(
'easy-thumbnails>=2.2,<3'
'PyPDF2', 'BeautifulSoup4', 'html5lib',
'slimit', 'lxml', 'static3==0.6.1', 'dj-static', 'chardet',
'csscompressor', 'mt-940', 'django-markup', 'markdown',
'django-capture-tag'
'csscompressor', 'mt-940', 'django-markup', 'markdown'
],
extras_require={
'dev': ['django-debug-toolbar>=1.3.0,<2.0'],

View File

@@ -1,82 +0,0 @@
/*globals $, Morris, gettext*/
$(function () {
if (!$("#voucher-create").length) {
return;
}
function show_step(state_el) {
var was_visible = state_el.is(':visible');
state_el.animate({
'height': 'show',
'opacity': 'show',
'padding-top': 'show',
'padding-bottom': 'show',
'margin-top': 'show',
'margin-bottom': 'show'
}, 400);
var offset = state_el.offset();
var body = $("html, body");
if (!was_visible && offset.top > $("body").scrollTop() + $(window).height() - 160) {
body.animate({scrollTop: offset.top + 200}, '400', 'swing');
}
}
if ($(".alert-danger").length === 0) {
$(".wizard-step, .wizard-advanced, #step-save").hide();
$(".wizard-step").first().show();
}
$("#id_number, #id_max_usages").on("change keydown keyup", function () {
if ($("#id_number").val() && $("#id_max_usages").val()) {
show_step($("#step-valid"));
}
});
$("#id_valid_until").on("focus change", function () {
$("input[name=has_valid_until][value=no]").prop("checked", false);
$("input[name=has_valid_until][value=yes]").prop("checked", true);
}).on("change dp.change", function () {
if ($("input[name=has_valid_until][value=no]").prop("checked") || $("#id_valid_until").val()) {
show_step($("#step-products"));
}
});
$("input[name=has_valid_until]").on("change", function () {
if ($("input[name=has_valid_until]").not("[value=on]").prop("checked") || $("#id_valid_until").val()) {
show_step($("#step-products"));
} else {
$("#id_valid_until").focus();
}
});
$("input[name=itemvar]").on("change", function () {
show_step($("#step-price"));
});
$("#step-price input").on("change keydown keyup", function () {
var mode = $("input[name=price_mode]:checked").val();
var show_next = (mode === 'none' || $("input[name='value_" + mode + "']").val());
if (show_next) {
show_step($("#step-block"));
} else {
$("input[name='value_" + mode + "']").focus();
}
});
$("#step-price input[type=text]").on("focus change keyup keydown", function () {
$("#step-price input[type=radio]").prop("checked", false);
$(this).closest(".radio").find("input[type=radio]").prop("checked", true);
});
$("input[name=block_quota]").on("change", function () {
show_step($("#step-advanced"));
show_step($("#step-save"));
});
$("#wizard-advanced-show").on("click", function (e) {
show_step($(".wizard-advanced"));
$(this).animate({'opacity': '0'}, 400);
e.preventDefault();
return true;
});
});

View File

@@ -117,4 +117,3 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
.ticketoutput-panel .panel-title {
line-height: 30px;
}

View File

@@ -1,35 +0,0 @@
#voucher-create {
.wizard-step {
}
.wizard-step-inner {
padding: 20px 0;
}
#id_number, #id_max_usages {
width: 75px;
}
.price {
width: 100px;
}
.form-options {
list-style: none;
padding: 10px;
}
.radio {
display: block;
line-height: 24px;
}
.radio-alt {
line-height: 40px;
}
.radio .help-block {
margin: 0;
padding-left: 17px;
line-height: 1.5;
}
.comment {
display: block;
textarea {
width: 100%;
}
}
}

View File

@@ -11,7 +11,6 @@ $fa-font-path: static("fontawesome/fonts");
@import "_flags.scss";
@import "_orders.scss";
@import "_dashboard.scss";
@import "_vouchers.scss";
@import "../../pretixbase/scss/webfont.scss";
footer {

View File

@@ -135,6 +135,7 @@ def test_metrics_view(monkeypatch, client):
metricsview.metrics.http_requests_total.inc(counter_value, code="200", handler="/foo", method="GET")
# test unauthorized-page
assert "You are not authorized" in metricsview.serve_metrics(None).content.decode('utf-8')
assert "You are not authorized" in client.get('/metrics').content.decode('utf-8')
assert "{} {}".format(fullname, counter_value) not in client.get('/metrics')

View File

@@ -1,10 +1,7 @@
import datetime
import sys
from datetime import timedelta
from decimal import Decimal
import pytest
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -247,8 +244,6 @@ class QuotaTestCase(BaseQuotaTestCase):
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
def test_voucher_multiuse_count_overredeemed(self):
if 'sqlite' not in settings.DATABASES['default']['ENGINE']:
pytest.xfail('This should raise a type error on most databases')
Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, max_usages=2, redeemed=4)
self.assertEqual(self.quota.count_blocking_vouchers(), 0)
@@ -407,29 +402,6 @@ class QuotaTestCase(BaseQuotaTestCase):
v.clean()
class VoucherTestCase(BaseQuotaTestCase):
def test_calculate_price_none(self):
v = Voucher.objects.create(event=self.event, price_mode='none', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('23.42')
def test_calculate_price_set_empty(self):
v = Voucher.objects.create(event=self.event, price_mode='set')
v.calculate_price(Decimal('23.42')) == Decimal('23.42')
def test_calculate_price_set(self):
v = Voucher.objects.create(event=self.event, price_mode='set', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('10.00')
def test_calculate_price_subtract(self):
v = Voucher.objects.create(event=self.event, price_mode='subtract', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('13.42')
def test_calculate_price_percent(self):
v = Voucher.objects.create(event=self.event, price_mode='percent', value=Decimal('23.00'))
v.calculate_price(Decimal('100.00')) == Decimal('77.00')
class OrderTestCase(BaseQuotaTestCase):
def setUp(self):
super().setUp()

View File

@@ -2,15 +2,13 @@ from datetime import datetime, timedelta
from decimal import Decimal
import pytest
import pytz
from django.test import TestCase
from django.utils.timezone import make_aware, now
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.payment import FreeOrderProvider
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, expire_orders,
OrderChangeManager, OrderError, _calculate_expiry, expire_orders,
)
@@ -29,10 +27,7 @@ def test_expiry_days(event):
today = now()
event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', False)
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 5
assert (_calculate_expiry(event, today) - today).days == 5
@pytest.mark.django_db
@@ -40,18 +35,12 @@ def test_expiry_weekdays(event):
today = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0))
event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', True)
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 6
assert order.expires.weekday() == 0
assert (_calculate_expiry(event, today) - today).days == 6
assert _calculate_expiry(event, today).weekday() == 0
today = make_aware(datetime(2016, 9, 19, 15, 0, 0, 0))
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 7
assert order.expires.weekday() == 0
assert (_calculate_expiry(event, today) - today).days == 7
assert _calculate_expiry(event, today).weekday() == 0
@pytest.mark.django_db
@@ -60,28 +49,9 @@ def test_expiry_last(event):
event.settings.set('payment_term_days', 5)
event.settings.set('payment_term_weekdays', False)
event.settings.set('payment_term_last', now() + timedelta(days=3))
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 3
assert (_calculate_expiry(event, today) - today).days == 3
event.settings.set('payment_term_last', now() + timedelta(days=7))
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
assert (order.expires - today).days == 5
@pytest.mark.django_db
def test_expiry_dst(event):
event.settings.set('timezone', 'Europe/Berlin')
tz = pytz.timezone('Europe/Berlin')
utc = pytz.timezone('UTC')
today = tz.localize(datetime(2016, 10, 29, 12, 0, 0)).astimezone(utc)
order = _create_order(event, email='dummy@example.org', positions=[],
now_dt=today, payment_provider=FreeOrderProvider(event),
locale='de')
localex = order.expires.astimezone(tz)
assert (localex.hour, localex.minute) == (23, 59)
assert (_calculate_expiry(event, today) - today).days == 5
@pytest.mark.django_db

View File

@@ -80,9 +80,9 @@ class VoucherFormTest(SoupTest):
def test_csv(self):
self.event.vouchers.create(item=self.ticket, code='ABCDEFG')
doc = self.client.get('/control/event/%s/%s/vouchers/?download=yes' % (self.orga.slug, self.event.slug))
assert doc.content.strip() == '"Voucher code","Valid until","Product","Reserve quota","Bypass quota",' \
'"Price effect","Value","Tag","Redeemed","Maximum usages"\r\n"ABCDEFG","",' \
'"Early-bird ticket","No","No","Set product price to","","","0","1"'.encode('utf-8')
assert doc.content.strip() == '"Voucher code","Valid until","Product","Reserve quota","Bypass quota","Price",' \
'"Tag","Redeemed","Maximum usages"\r\n"ABCDEFG","","Early-bird ticket","No",' \
'"No","","","0","1"'.encode('utf-8')
def test_filter_status_valid(self):
v = self.event.vouchers.create(item=self.ticket)

View File

@@ -121,39 +121,6 @@ def test_webhook_all_good(env, client, monkeypatch):
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
charge = get_test_charge(env[1])
monkeypatch.setattr("stripe.Charge.retrieve", lambda *args: charge)
client.post('/dummy/dummy/stripe/webhook/', json.dumps(
{
"id": "evt_18otImGGWE2Ias8TUyVRDB1G",
"object": "event",
"api_version": "2016-03-07",
"created": 1472729052,
"data": {
"object": {
"id": "ch_18TY6GGGWE2Ias8TZHanef25",
"object": "charge",
# Rest of object is ignored anway
}
},
"livemode": True,
"pending_webhooks": 1,
"request": "req_977XOWC8zk51Z9",
"type": "charge.succeeded"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_partial_refund(env, client, monkeypatch):
charge = get_test_charge(env[1])

View File

@@ -349,6 +349,20 @@ class CartTest(CartTestMixin, TestCase):
self.assertIsNone(objs[0].variation)
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):
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -488,7 +502,7 @@ class CartTest(CartTestMixin, TestCase):
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_red,
price=14, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, value=Decimal('10.00'), event=self.event)
v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, price=Decimal('10.00'), event=self.event)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'variation_%d_%d_voucher' % (self.shirt.id, self.shirt_red.id): v.code,
}, follow=True)
@@ -539,10 +553,10 @@ class CartTest(CartTestMixin, TestCase):
)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
'_voucher_code': v.code,
}, follow=True)
obj = CartPosition.objects.get(id=cp1.id)
self.assertGreater(obj.expires, now())
self.assertEqual(obj.voucher, v)
def test_voucher_variation(self):
v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, event=self.event)
@@ -594,7 +608,7 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(len(objs), 0)
def test_voucher_price(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event)
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
@@ -605,76 +619,8 @@ class CartTest(CartTestMixin, TestCase):
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('12.00'))
def test_voucher_price_percent(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='percent', event=self.event)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
}, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('20.70'))
def test_voucher_price_subtract(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='subtract', event=self.event)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
}, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('13.00'))
def test_voucher_free_price(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='percent', event=self.event)
self.ticket.free_price = True
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '21.00',
'_voucher_code': v.code,
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('21', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('21', doc.select('.cart .cart-row')[0].select('.price')[1].text)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('21.00'))
def test_voucher_free_price_lower_bound(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='percent', event=self.event)
self.ticket.free_price = False
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '20.00',
'_voucher_code': v.code,
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('20.70', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('20.70', doc.select('.cart .cart-row')[0].select('.price')[1].text)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('20.70'))
def test_voucher_redemed(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, redeemed=1)
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, redeemed=1)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
@@ -684,7 +630,7 @@ class CartTest(CartTestMixin, TestCase):
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
def test_voucher_expired(self):
v = Voucher.objects.create(item=self.ticket, value=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))
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -706,7 +652,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_quota_empty(self):
self.quota_tickets.size = 0
self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event)
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
@@ -718,7 +664,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_quota_ignore(self):
self.quota_tickets.size = 0
self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
allow_ignore_quota=True)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -733,7 +679,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_quota_block(self):
self.quota_tickets.size = 1
self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
block_quota=True)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -752,7 +698,7 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(objs[0].price, Decimal('12.00'))
def test_voucher_doubled(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event)
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'_voucher_code': v.code,
@@ -826,7 +772,7 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(len(objs), 0)
def test_voucher_multiuse_ok(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=0)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '2',
@@ -837,7 +783,7 @@ class CartTest(CartTestMixin, TestCase):
assert all(cp.voucher == v for cp in positions)
def test_voucher_multiuse_multiprod_ok(self):
v = Voucher.objects.create(quota=self.quota_all, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(quota=self.quota_all, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=0)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -849,7 +795,7 @@ class CartTest(CartTestMixin, TestCase):
assert all(cp.voucher == v for cp in positions)
def test_voucher_multiuse_partially(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=1)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '2',
@@ -861,7 +807,7 @@ class CartTest(CartTestMixin, TestCase):
assert not positions.exists()
def test_voucher_multiuse_multiprod_partially(self):
v = Voucher.objects.create(quota=self.quota_all, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(quota=self.quota_all, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=1)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -869,13 +815,12 @@ class CartTest(CartTestMixin, TestCase):
'_voucher_code': v.code,
}, follow=True)
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)
assert positions.count() == 1
assert all(cp.voucher == v for cp in positions)
assert positions.count() == 0
def test_voucher_multiuse_redeemed(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=2)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '2',
@@ -887,7 +832,7 @@ class CartTest(CartTestMixin, TestCase):
assert not positions.exists()
def test_voucher_multiuse_multiprod_redeemed(self):
v = Voucher.objects.create(quota=self.quota_all, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(quota=self.quota_all, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=2)
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
@@ -900,7 +845,7 @@ class CartTest(CartTestMixin, TestCase):
assert not positions.exists()
def test_voucher_multiuse_redeemed_in_my_cart(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=1)
CartPosition.objects.create(
expires=now() - timedelta(minutes=10), item=self.ticket, voucher=v, price=Decimal('12.00'),
@@ -911,12 +856,12 @@ class CartTest(CartTestMixin, TestCase):
'_voucher_code': v.code,
}, follow=True)
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)
assert positions.count() == 1
assert positions.count() == 0
def test_voucher_multiuse_redeemed_in_other_cart(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=1)
CartPosition.objects.create(
expires=now() + timedelta(minutes=10), item=self.ticket, voucher=v, price=Decimal('12.00'),
@@ -932,7 +877,7 @@ class CartTest(CartTestMixin, TestCase):
assert not positions.exists()
def test_voucher_multiuse_redeemed_in_other_expired_cart(self):
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
max_usages=2, redeemed=1)
CartPosition.objects.create(
expires=now() - timedelta(minutes=10), item=self.ticket, voucher=v, price=Decimal('12.00'),

View File

@@ -294,7 +294,7 @@ class CheckoutTestCase(TestCase):
self.assertEqual(cr1.price, 24)
def test_voucher(self):
v = Voucher.objects.create(item=self.ticket, value=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))
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -312,7 +312,7 @@ class CheckoutTestCase(TestCase):
self.assertEqual(Voucher.objects.get(pk=v.pk).redeemed, 1)
def test_voucher_required(self):
v = Voucher.objects.create(item=self.ticket, value=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))
self.ticket.require_voucher = True
self.ticket.save()
@@ -341,7 +341,7 @@ class CheckoutTestCase(TestCase):
assert doc.select(".alert-danger")
def test_voucher_price_changed(self):
v = Voucher.objects.create(item=self.ticket, value=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))
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -355,8 +355,20 @@ class CheckoutTestCase(TestCase):
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.price, Decimal('12.00'))
def test_voucher_expired(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
valid_until=now() - timedelta(days=2))
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.assertIn("expired", doc.select(".alert-danger")[0].text)
def test_voucher_redeemed(self):
v = Voucher.objects.create(item=self.ticket, value=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), redeemed=1)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -368,7 +380,7 @@ class CheckoutTestCase(TestCase):
self.assertIn("has already been", doc.select(".alert-danger")[0].text)
def test_voucher_multiuse_redeemed(self):
v = Voucher.objects.create(item=self.ticket, value=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=3)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -380,7 +392,7 @@ class CheckoutTestCase(TestCase):
self.assertIn("has already been", doc.select(".alert-danger")[0].text)
def test_voucher_multiuse_partially(self):
v = Voucher.objects.create(item=self.ticket, value=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=2)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -393,11 +405,32 @@ class CheckoutTestCase(TestCase):
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.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
def test_voucher_multiuse_ok(self):
v = Voucher.objects.create(item=self.ticket, value=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)
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,
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,
@@ -418,7 +451,7 @@ class CheckoutTestCase(TestCase):
assert v.redeemed == 3
def test_voucher_multiuse_in_other_cart_expired(self):
v = Voucher.objects.create(item=self.ticket, value=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)
CartPosition.objects.create(
event=self.event, cart_id='other', item=self.ticket,
@@ -443,7 +476,7 @@ class CheckoutTestCase(TestCase):
assert v.redeemed == 3
def test_voucher_multiuse_in_other_cart(self):
v = Voucher.objects.create(item=self.ticket, value=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)
CartPosition.objects.create(
event=self.event, cart_id='other', item=self.ticket,
@@ -460,13 +493,13 @@ class CheckoutTestCase(TestCase):
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.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
def test_voucher_ignore_quota(self):
self.quota_tickets.size = 0
self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, value=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), allow_ignore_quota=True)
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -484,7 +517,7 @@ class CheckoutTestCase(TestCase):
def test_voucher_block_quota(self):
self.quota_tickets.size = 1
self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, value=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), block_quota=True)
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -511,7 +544,7 @@ class CheckoutTestCase(TestCase):
self.quota_tickets.save()
q2 = self.event.quotas.create(name='Testquota', size=0)
q2.items.add(self.ticket)
v = Voucher.objects.create(quota=self.quota_tickets, value=Decimal('12.00'), event=self.event,
v = Voucher.objects.create(quota=self.quota_tickets, price=Decimal('12.00'), event=self.event,
valid_until=now() + timedelta(days=2), block_quota=True)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,

View File

@@ -285,27 +285,19 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest):
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, self.v.code))
assert "_voucher_item" in html.rendered_content
def test_voucher_price(self):
self.v.value = Decimal("10.00")
def test_special_price(self):
self.v.price = Decimal("10.00")
self.v.save()
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, self.v.code))
assert "Early-bird" in html.rendered_content
assert "10.00" in html.rendered_content
def test_voucher_price_percentage(self):
self.v.value = Decimal("10.00")
self.v.price_mode = 'percent'
self.v.save()
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, self.v.code))
assert "Early-bird" in html.rendered_content
assert "10.80" in html.rendered_content
def test_voucher_price_variations(self):
def test_special_price_variations(self):
var1 = ItemVariation.objects.create(item=self.item, value='Red', default_price=14, position=1)
var2 = ItemVariation.objects.create(item=self.item, value='Black', position=2)
self.q.variations.add(var1)
self.q.variations.add(var2)
self.v.value = Decimal("10.00")
self.v.price = Decimal("10.00")
self.v.save()
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, self.v.code))
assert "Early-bird" in html.rendered_content

View File

@@ -9,4 +9,8 @@ TEMPLATES[0]['DIRS'].append(os.path.join(TEST_DIR, 'templates')) # NOQA
INSTALLED_APPS.append('tests.testdummy') # NOQA
for a in PLUGINS:
INSTALLED_APPS.remove(a)
INSTALLED_APPS.remove(a)
DATABASES['default'] = {
'ENGINE': 'django.db.backends.sqlite3',
}