Merge pull request #111 from pretix/remove-restrictions

Remove restrictions
This commit is contained in:
Raphael Michel
2015-12-06 18:24:43 +01:00
31 changed files with 174 additions and 1277 deletions

View File

@@ -7,7 +7,6 @@ Contents:
:maxdepth: 2
plugins
restriction
payment
ticketoutput
exporter

View File

@@ -1,306 +0,0 @@
.. highlight:: python
:linenothreshold: 5
Writing a restriction plugin
============================
Please make sure you have read and understood the :ref:`basic idea <restrictionconcept>` behind
what pretix calls *restrictions*. In this document, we will walk through the creation of a
restriction plugin using the example of a restriction by date and time.
Also, read :ref:`Creating a plugin <pluginsetup>` first.
The restriction model
---------------------
It is very likely that your new restriction plugin needs to store data. In order to do
so, it should define its own model with a name related to what your restriction does,
e.g. ``TimeRestriction``. This model should be a child class of ``pretix.base.models.BaseRestriction``.
You do not need to define custom fields, but you should create at least an empty model.
In our example, we put the following into :file:`pretix/plugins/timerestriction/models.py`::
from django.db import models
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import BaseRestriction
class TimeRestriction(BaseRestriction):
"""
This restriction makes an item or variation only available
within a given time frame. The price of the item can be modified
during this time frame.
"""
timeframe_from = models.DateTimeField(
verbose_name=_("Start of time frame"),
)
timeframe_to = models.DateTimeField(
verbose_name=_("End of time frame"),
)
price = models.DecimalField(
null=True, blank=True,
max_digits=7, decimal_places=2,
verbose_name=_("Price in time frame"),
)
The basic signals
-----------------
Availability determination
^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the one signal *every* restriction plugin has to listen for, as your plugin does not
restrict anything without doing so. It is available as ``pretix.base.signals.determine_availability``
and is sent out every time some component of pretix wants to know whether a specific item or
variation is available for sell.
It is sent out with several keyword arguments:
``item``
The instance of ``pretix.base.models.Item`` in question.
``variations``
A list of dictionaries in the same format as ``Item.get_all_variations``:
The list contains one dictionary per variation, where the ``Property`` IDs are
keys and the ``PropertyValue`` objects are values. If an ``ItemVariation`` object
exists, it is available in the dictionary via the special key ``'variation'``. If
the item does not have any properties, the list will contain exactly one empty
dictionary. Please note: this is *not* the list of all possible variations, this is
only the list of all variations the front end likes to determine the status for.
Technically, you won't get ``dict`` objects but ``pretix.base.types.VariationDict``
objects, which behave exactly the same but add some extra methods.
``context``
A yet-to-be-defined context object containing information about the user and the order
process. This is required to implement coupon-systems or similar restrictions.
``cache``
An object very similar to Django's own caching API (see tip below)
The positional argument ``sender`` contains the event.
All receivers **have to** return a copy of the given list of variation dictionaries where each
dictionary can be extended by the following two keys:
``available``
A boolean value whether or not this plugin allows this variation to be on sale. Defaults
to ``True``.
``price``
A price to be set for this variation. Set to ``None`` or omit to keep the default price
of the variation or the item's base price.
.. IMPORTANT::
As this signal might be called *a lot* under heavy load, you are expected to implement
your receiver with an eye to performance. We highly recommend making use of Django's
`caching feature`_. We cannot do this for you, as the possibility of caching highly
depends on the details of your restriction.
**Attention:** Please use the **cache object provided in the signal** instead of importing
it directly from django, so we can take care of invalidation whenever the organizer changes
the event or item settings. Please also **prefix all your cache keys** with your
plugin name.
In our example, the implementation could look like this::
from django.dispatch import receiver
from django.utils.timezone import now
from pretix.base.signals import determine_availability
from .models import TimeRestriction
@receiver(determine_availability, dispatch_uid="restriction_time")
def availability_handler(sender, **kwargs):
# Handle the signal's input arguments
item = kwargs['item']
variations = kwargs['variations']
cache = kwargs['cache']
context = kwargs['context'] # NOQA
# Fetch all restriction objects applied to this item
restrictions = list(TimeRestriction.objects.filter(
item=item,
).prefetch_related('variations'))
# If we do not know anything about this item, we are done here.
if len(restrictions) == 0:
return variations
# IMPORTANT:
# We need to make a two-level deep copy of the variations list before we
# modify it, becuase we need to to copy the dictionaries. Otherwise, we'll
# interfere with other plugins.
variations = [d.copy() for d in variations]
# The maximum validity of our cached values is the next date, one of our
# timeframe_from or tiemframe_to actions happens
def timediff(restrictions):
for r in restrictions:
if r.timeframe_from >= now():
yield (r.timeframe_from - now()).total_seconds()
if r.timeframe_to >= now():
yield (r.timeframe_to - now()).total_seconds()
try:
cache_validity = min(timediff(restrictions))
except ValueError:
# empty sequence
# If we get here, there are restrictions available but nothing will
# change about them any more. If it were not for the case of no
# restriction for the base item but restrictions for special
# variations, we could quit here with 'item not available'.
cache_validity = 3600
# Walk through all variations we are asked for
for v in variations:
var_restrictions = []
for restriction in restrictions:
applied_to = list(restriction.variations.current.all())
# Only take this restriction into consideration if it
# is directly applied to this variation or if the item
# has no variations
if v.empty() or ('variation' in v and v['variation'] in applied_to):
var_restrictions.append(restriction)
if not var_restrictions:
v['available'] = True
v['price'] = None
continue
# If this point is reached, there ARE time restrictions for this item
# Therefore, it is only available inside one of the timeframes, but not
# without any timeframe
available = False
# Make up some unique key for this variation
cachekey = 'timerestriction:%d:%s' % (
item.pk,
v.identify(),
)
# Fetch from cache, if available
cached = cache.get(cachekey)
if cached is not None:
v['available'] = (cached.split(":")[0] == 'True')
try:
v['price'] = float(cached.split(":")[1])
except ValueError:
v['price'] = None
continue
# Walk through all restriction objects applied to this item
prices = []
for restriction in var_restrictions:
if restriction.timeframe_from <= now() <= restriction.timeframe_to:
# Selling this item is currently possible
available = True
prices.append(restriction.price)
# Use the lowest of all prices set by restrictions
prices = [p for p in prices if p is not None]
price = min(prices) if prices else None
v['available'] = available
v['price'] = price
cache.set(
cachekey,
'%s:%s' % (
'True' if available else 'False',
str(price) if price else ''
),
cache_validity
)
return variations
.. IMPORTANT::
Please note the copying of the ``variations`` list in the example above (line 30).
If you do not copy down to the ``dict`` objects, you will run into
interference problems with other plugins.
Control interface formsets
^^^^^^^^^^^^^^^^^^^^^^^^^^
To make it possible for the event organizer to configure your restriction, there is a
'Restrictions' page in the item configuration. This page is able to show a formset for
each restriction plugin, but *you* are required to create this formset. This is why you
should listen to the ``pretix.control.signals.restriction_formset`` signal.
Currently, the signal comes with only one keyword argument:
``item``
The instance of ``pretix.base.models.Item`` we want a formset for.
You are expected to return a dict containing the following items:
``formsetclass``
An inline formset class (not a formset object).
``prefix``
A unique prefix for your queryset.
``title``
A title for your formset (normally your plugin name)
``description``
An short, explanatory text about your restriction.
Our time restriction example looks like this::
from django.utils.translation import ugettext_lazy as _
from django.dispatch import receiver
from django.forms.models import inlineformset_factory
from pretix.control.signals import restriction_formset
from pretix.base.models import Item
from pretix.control.forms import (
VariationsField, RestrictionInlineFormset, RestrictionForm
)
from .models import TimeRestriction
class TimeRestrictionForm(RestrictionForm):
class Meta:
model = TimeRestriction
localized_fields = '__all__'
fields = [
'variations',
'timeframe_from',
'timeframe_to',
'price',
]
@receiver(restriction_formset, dispatch_uid="restriction_formset_time")
def formset_handler(sender, **kwargs):
formset = inlineformset_factory(
Item,
TimeRestriction,
formset=RestrictionInlineFormset,
form=TimeRestrictionForm,
can_order=False,
can_delete=True,
extra=0,
)
return {
'title': _('Restriction by time'),
'formsetclass': formset,
'prefix': 'timerestriction',
'description': 'If you use this restriction type, the system will only '
'sell variations which are covered by at least one of the '
'timeframes you define below.'
}
.. NOTE::
If you do use the ``RestrictionInlineFormset``, ``RestrictionForm`` and
``VariationsField`` classes in your implementation, we will do a lot of magic for you
to display the ``variations`` field in the form in a nice and consistent way. So please,
use these base classes and test carefully, if you make any changes to the behaviour
of this field.
.. _caching feature: https://docs.djangoproject.com/en/1.7/topics/cache/

View File

@@ -53,49 +53,6 @@ An item can be extended using **questions**. Questions enable items to be extend
additional information which can be entered by the user. Examples of possible questions
include 'Name' or 'age'.
.. _restrictionconcept:
Restrictions
^^^^^^^^^^^^
The probably most powerful concepts of pretix is the very abstract concept of **restrictions**.
We already know that **items** can come in very different **variations**, but a
**restriction** decides whether an variation is available for sale and assign **prices**
to **variations**. There are **restriction types** (pieces of code implementing the
restriction logic) and **restriction instances** (the specific configurations made by the
organizer). Although **restrictions** are a very abstract concept which can be used
to do nearly anything, there are a few obvious examples:
* One easy example is a restriction by time, which allows the sale of certain item variations
only within a certain time frame. As restrictions can also assign a price to a variation,
this can also be used to implement something like 'early-bird prices' for your tickets by
using multiple time restrictions with different prices.
* The most obvious example is the restriction by number, which limits the sale of the tickets to
a maximum number. You can use this either to stop selling tickets completely when your house
is full or for creating limited 'VIP tickets'. We'll come to this again later.
* A more advanced example is a restriction by user, for example reduced ticket prices for
users who are members of a special group.
* Arbitrary sophisticated features like coupon codes can also be implemented using
this feature.
Any number of **restrictions** can be applied to the whole of a **item** or even to a specific
**variation**. The processing of the restriction follows the following set of rules:
* Variation-specific rules have precedence over item-specific rules.
* The restrictions are being processed in random order (there may not be any assumptions about
the evaluation order).
* Multiple restriction instances of **different restriction types** are linked with *and*, so
if both a time frame and a restriction by number are applied to an item, the item is only available
for sale during the given time frame *and* only as long as items are available.
* Multiple restriction instances of the **same restriction type** are typically linked with *or*,
although this is the decision of the restriction logic itself and not mandatory. So for example
the restriction by time would implement this default logic, because if two time frames are applied
to an item, the item should be available for sale in both of the time frames (it just does not make
sense otherwise on an one-dimensional time axis).
* If multiple restrictions apply which set the price, the *cheapest* price determines the final price.
Restrictions can be implemented using a plugin system and do not require changes to the pretix codebase.
Restriction by number
"""""""""""""""""""""

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0004_auto_20151024_0848'),
]
operations = [
migrations.AddField(
model_name='item',
name='available_from',
field=models.DateTimeField(null=True, help_text='This product will not be sold before the given date.', blank=True, verbose_name='Available from'),
),
migrations.AddField(
model_name='item',
name='available_until',
field=models.DateTimeField(null=True, help_text='This product will not be sold after the given date.', blank=True, verbose_name='Available to'),
),
]

View File

@@ -2,8 +2,8 @@ from .auth import User
from .base import CachedFile, Versionable, cachedfile_name
from .event import Event, EventLock, EventPermission, EventSetting
from .items import (
BaseRestriction, Item, ItemCategory, ItemVariation, Property,
PropertyValue, Question, Quota, VariationsField, itempicture_upload_to,
Item, ItemCategory, ItemVariation, Property, PropertyValue, Question,
Quota, VariationsField, itempicture_upload_to,
)
from .orders import (
CachedTicket, CartPosition, ObjectWithAnswers, Order, OrderPosition,
@@ -14,7 +14,7 @@ from .organizer import Organizer, OrganizerPermission, OrganizerSetting
__all__ = [
'Versionable', 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission',
'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question',
'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'ObjectWithAnswers', 'OrderPosition',
'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'ObjectWithAnswers', 'OrderPosition',
'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to',
'generate_secret'
]

View File

@@ -1,6 +1,5 @@
import sys
from datetime import datetime
from decimal import Decimal
from itertools import product
from django.db import models
@@ -8,7 +7,7 @@ from django.db.models import Q, Case, Count, Sum, When
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from typing import List, Tuple, Union
from typing import List, Tuple
from versions.models import VersionedForeignKey, VersionedManyToManyField
from pretix.base.i18n import I18nCharField, I18nTextField
@@ -80,8 +79,6 @@ class Item(Versionable):
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
Items are often also called 'products' but are named 'items' internally due to historic reasons.
It has a default price which might by overriden by restrictions.
:param event: The event this belongs to.
:type event: Event
:param category: The category this belongs to. May be null.
@@ -100,6 +97,10 @@ class Item(Versionable):
:type admission: bool
:param picture: A product picture to be shown next to the product description.
:type picture: File
:param available_from: The date this product goes on sale
:type available_from: datetime
:param available_until: The date until when the product is on sale
:type available_until: datetime
"""
@@ -154,6 +155,16 @@ class Item(Versionable):
null=True, blank=True,
upload_to=itempicture_upload_to
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True, blank=True,
help_text=_('This product will not be sold before the given date.')
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True, blank=True,
help_text=_('This product will not be sold after the given date.')
)
class Meta:
verbose_name = _("Product")
@@ -173,6 +184,19 @@ class Item(Versionable):
if self.event:
self.event.get_cache().clear()
def is_available(self) -> bool:
"""
Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields
"""
if not self.active:
return False
if self.available_from and self.available_from > now():
return False
if self.available_until and self.available_until < now():
return False
return True
def get_all_variations(self, use_cache: bool=False) -> List[VariationDict]:
"""
This method returns a list containing all variations of this
@@ -249,10 +273,9 @@ class Item(Versionable):
def get_all_available_variations(self, use_cache: bool=False):
"""
This method returns a list of all variations which are theoretically
possible for sale. It DOES call all activated restriction plugins, and it
DOES only return variations which DO have an ItemVariation object, as all
variations without one CAN NOT be part of a Quota and therefore can never
be available for sale. The only exception is the empty variation
possible for sale. It DOES only return variations which DO have an ItemVariation
object, as all variations without one CAN NOT be part of a Quota and therefore can
never be available for sale. The only exception is the empty variation
for items without properties, which never has an ItemVariation object.
This DOES NOT take into account quotas itself. Use ``is_available`` on the
@@ -268,39 +291,18 @@ class Item(Versionable):
if use_cache and hasattr(self, '_get_all_available_variations_cache'):
return self._get_all_available_variations_cache
from pretix.base.signals import determine_availability
variations = self._get_all_generated_variations()
responses = determine_availability.send(
self.event, item=self,
variations=variations, context=None,
cache=self.event.get_cache()
)
for i, var in enumerate(variations):
var['available'] = var['variation'].active if 'variation' in var else True
if 'variation' in var:
if var['variation'].default_price:
if var['variation'].default_price is not None:
var['price'] = var['variation'].default_price
else:
var['price'] = self.default_price
else:
var['price'] = self.default_price
# It is possible, that *multiple* restriction plugins change the default price.
# In this case, the cheapest one wins. As soon as there is a restriction
# that changes the price, the default price has no effect.
newprice = None
for rec, response in responses:
if 'available' in response[i] and not response[i]['available']:
var['available'] = False
break
if 'price' in response[i] and response[i]['price'] is not None \
and (newprice is None or response[i]['price'] < newprice):
newprice = response[i]['price']
var['price'] = newprice or var['price']
variations = [var for var in variations if var['available']]
self._get_all_available_variations_cache = variations
@@ -322,38 +324,6 @@ class Item(Versionable):
return min([q.availability() for q in self.quotas.all()],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
def check_restrictions(self):
"""
This method is used to determine whether this ItemVariation is restricted
in sale by any restriction plugins.
:returns:
* ``False``, if the item is unavailable
* the item's price, otherwise
:raises ValueError: if you call this on an item which has properties associated with it.
Please use the method on the ItemVariation object you are interested in.
"""
if self.properties.count() > 0: # NOQA
raise ValueError('Do not call this directly on items which have properties '
'but call this on their ItemVariation objects')
from pretix.base.signals import determine_availability
vd = VariationDict()
responses = determine_availability.send(
self.event, item=self,
variations=[vd], context=None,
cache=self.event.get_cache()
)
price = self.default_price
for rec, response in responses:
if 'available' in response[0] and not response[0]['available']:
return False
elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price:
price = response[0]['price']
return price
class Property(Versionable):
"""
@@ -466,8 +436,6 @@ class ItemVariation(Versionable):
values by creating an ItemVariation object for them with active set to
False.
Restrictions can be not only set to items but also directly to variations.
:param item: The item this variation belongs to
:type item: Item
:param values: A set of ``PropertyValue`` objects defining this variation
@@ -531,31 +499,6 @@ class ItemVariation(Versionable):
vd['variation'] = self
return vd
def check_restrictions(self) -> Union[bool, Decimal]:
"""
This method is used to determine whether this ItemVariation is restricted
in sale by any restriction plugins.
:returns:
* ``False``, if the item is unavailable
* the item's price, otherwise
"""
from pretix.base.signals import determine_availability
responses = determine_availability.send(
self.item.event, item=self.item,
variations=[self.to_variation_dict()], context=None,
cache=self.item.event.get_cache()
)
price = self.default_price if self.default_price is not None else self.item.default_price
for rec, response in responses:
if 'available' in response[0] and not response[0]['available']:
return False
elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price:
price = response[0]['price']
return price
def add_values_from_string(self, pk):
"""
Add values to this ItemVariation using a serialized string of the form
@@ -672,47 +615,6 @@ class Question(Versionable):
self.event.get_cache().clear()
class BaseRestriction(Versionable):
"""
A restriction is the abstract concept of a rule that limits the availability
of Items or ItemVariations. This model is just an abstract base class to be
extended by restriction plugins.
"""
event = VersionedForeignKey(
Event,
on_delete=models.CASCADE,
related_name="restrictions_%(app_label)s_%(class)s",
verbose_name=_("Event"),
)
item = VersionedForeignKey(
Item,
blank=True, null=True,
verbose_name=_("Item"),
related_name="restrictions_%(app_label)s_%(class)s",
)
variations = VariationsField(
'pretixbase.ItemVariation',
blank=True,
verbose_name=_("Variations"),
related_name="restrictions_%(app_label)s_%(class)s",
)
class Meta:
abstract = True
verbose_name = _("Restriction")
verbose_name_plural = _("Restrictions")
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
class Quota(Versionable):
"""
A quota is a "pool of tickets". It is there to limit the number of items

View File

@@ -90,15 +90,10 @@ def _add_items(event: Event, items: List[Tuple[str, Optional[str], int]],
item = items_cache[i[0]]
variation = variations_cache[i[1]] if i[1] is not None else None
# Execute restriction plugins to check whether they (a) change the price or
# (b) make the item/variation unavailable. If neither is the case, check_restriction
# will correctly return the default price
price = item.check_restrictions() if variation is None else variation.check_restrictions()
# 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 price is False or len(quotas) == 0 or not item.active:
if len(quotas) == 0 or not item.is_available():
err = err or error_messages['unavailable']
continue
@@ -121,11 +116,15 @@ def _add_items(event: Event, items: List[Tuple[str, Optional[str], int]],
# Recreating
cp = i[3].clone()
cp.expires = expiry
cp.price = price
cp.price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
cp.save()
else:
CartPosition.objects.create(
event=event, item=item, variation=variation, price=price, expires=expiry,
event=event, item=item, variation=variation,
price=item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price),
expires=expiry,
cart_id=cart_id
)
return err

View File

@@ -103,7 +103,8 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]):
if cp.expires >= dt:
# Other checks are not necessary
continue
price = cp.item.check_restrictions() if cp.variation is None else cp.variation.check_restrictions()
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:
err = err or error_messages['unavailable']
cp.delete()

View File

@@ -46,15 +46,6 @@ class EventPluginSignal(django.dispatch.Signal):
responses.append((receiver, response))
return responses
"""
This signal is sent out every time some component of pretix wants to know whether a specific
item or variation is available for sell. The item will only be sold, if all (active) receivers
return a positive result (see plugin API documentation for details).
"""
determine_availability = EventPluginSignal(
providing_args=["item", "variations", "context", "cache"]
)
"""
This signal is sent out to get all known payment providers. Receivers should return a
subclass of pretix.base.payment.BasePaymentProvider

View File

@@ -99,60 +99,6 @@ class TolerantFormsetModelForm(VersionedModelForm):
return False
class RestrictionForm(TolerantFormsetModelForm):
"""
The restriction form provides useful functionality for all forms
representing a restriction instance. To be concret, this form does
the necessary magic to make the 'variations' field work correctly
and look beautiful.
"""
def __init__(self, *args, **kwargs):
if 'item' in kwargs:
self.item = kwargs['item']
del kwargs['item']
super().__init__(*args, **kwargs)
if 'variations' in self.fields and isinstance(self.fields['variations'], VariationsField):
self.fields['variations'].set_item(self.item)
class RestrictionInlineFormset(forms.BaseInlineFormSet):
"""
This is the base class you should use for any formset you return
from a ``restriction_formset`` signal receiver that contains
RestrictionForm objects as its forms, as it correcly handles the
necessary item parameter for the RestrictionForm. While this could
be achieved with a regular formset, this also adds a
``initialized_empty_form`` method which is the only way to correctly
render a working empty form for a JavaScript-enabled restriction formset.
"""
def __init__(self, data=None, files=None, instance=None,
save_as_new=False, prefix=None, queryset=None, **kwargs):
super().__init__(
data, files, instance, save_as_new, prefix, queryset, **kwargs
)
if isinstance(self.instance, Item):
self.queryset = self.queryset.as_of().prefetch_related("variations")
def initialized_empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
item=self.instance
)
self.add_fields(form, None)
return form
def _construct_form(self, i, **kwargs):
kwargs['item'] = self.instance
return super()._construct_form(i, **kwargs)
class Meta:
exclude = ['item']
def selector(values, prop):
# Given an iterable of PropertyValue objects, this will return a
# list of their primary keys, ordered by the primary keys of the

View File

@@ -137,6 +137,8 @@ class ItemFormGeneral(VersionedModelForm):
'picture',
'default_price',
'tax_rate',
'available_from',
'available_until',
]

View File

@@ -8,7 +8,6 @@
<li {% if "event.item" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=object.identity %}">{% trans "General information" %}</a></li>
<li {% if "event.item.properties" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.properties' organizer=request.event.organizer.slug event=request.event.slug item=object.identity %}">{% trans "Properties" %}</a></li>
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.identity %}">{% trans "Variations" %}</a></li>
<li {% if "event.item.restrictions" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.restrictions' organizer=request.event.organizer.slug event=request.event.slug item=object.identity %}">{% trans "Restrictions" %}</a></li>
</ul>
{% else %}
<h1>{% trans "Create product" %}</h1>

View File

@@ -18,6 +18,11 @@
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -1,72 +0,0 @@
{% extends "pretixcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block inside %}
<p>{% blocktrans trimmed %}
In this area, you can choose of a set of "restriction types" to restrict the availability of your product with
certain conditions.
{% endblocktrans %}</p>
<form action="" method="post">
{% csrf_token %}
{% for set in formsets %}
<fieldset>
<legend>{{ set.title }}</legend>
{% bootstrap_formset_errors set %}
<p>{{ set.description }}</p>
<div data-formset class="restriction-formset" data-formset-prefix="{{ set.formset.prefix }}">
<div data-formset-body class="panel-group collapsible" id="accordion_{{ set.formset.prefix }}">
{{ set.formset.management_form }}
{% for f in set.formset %}
<div class="panel panel-default" data-formset-form>
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion"
href="#collapse{{ f.prefix }}">
{{ set.title }}
</a>
</h4>
</div>
<div id="collapse{{ f.prefix }}" class="panel-collapse collapse in">
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_form f layout="horizontal" %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapse__prefix__">
{% trans "New restriction" %}
</a>
</h4>
</div>
<div id="collapse__prefix__" class="panel-collapse collapse in">
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_form set.formset.initialized_empty_form layout="horizontal" %}
</div>
</div>
</div>
</div>
{% endescapescript %}
</script>
<button type="button" class="btn btn-default" data-formset-add><i
class="fa fa-plus"></i> {% trans "Add a new restriction" %}</button>
</div>
</fieldset>
{% endfor %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -30,8 +30,6 @@ urlpatterns = [
url(r'^items/(?P<item>[0-9a-f-]+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
url(r'^items/(?P<item>[0-9a-f-]+)/variations$', item.ItemVariations.as_view(),
name='event.item.variations'),
url(r'^items/(?P<item>[0-9a-f-]+)/restrictions$', item.ItemRestrictions.as_view(),
name='event.item.restrictions'),
url(r'^items/(?P<item>[0-9a-f-]+)/properties$', item.ItemProperties.as_view(),
name='event.item.properties'),
url(r'^items/(?P<item>[0-9a-f-]+)/up$', item.item_move_up, name='event.items.up'),

View File

@@ -740,68 +740,6 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
return context
class ItemRestrictions(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/item/restrictions.html'
def get_formsets(self):
responses = restriction_formset.send(self.object.event, item=self.object)
formsets = []
for receiver, response in responses:
response['formset'] = response['formsetclass'](
self.request.POST if self.request.method == 'POST' else None,
instance=self.object,
prefix=response['prefix'],
)
formsets.append(response)
return formsets
def main(self, request, *args, **kwargs):
self.object = self.get_object()
self.request = request
self.formsets = self.get_formsets()
def get(self, request, *args, **kwargs):
self.main(request, *args, **kwargs)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
@transaction.atomic()
def post(self, request, *args, **kwargs):
self.main(request, *args, **kwargs)
valid = True
for f in self.formsets:
valid &= f['formset'].is_valid()
if valid:
for f in self.formsets:
for form in f['formset']:
if 'DELETE' in form.cleaned_data and form.cleaned_data['DELETE'] is True:
if form.instance.pk is None:
continue
form.instance.delete()
else:
form.instance.event = request.event
form.instance.item = self.object
form.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['formsets'] = self.formsets
return context
def get_success_url(self) -> str:
return reverse('control:event.item.restrictions', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'item': self.object.identity
})
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
model = Item
template_name = 'pretixcontrol/item/delete.html'

View File

@@ -1,24 +0,0 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from pretix import __version__ as version
from pretix.base.plugins import PluginType
class TimeRestrictionApp(AppConfig):
name = 'pretix.plugins.timerestriction'
verbose_name = _("Time restriction")
class PretixPluginMeta:
type = PluginType.RESTRICTION
name = _("Restriction by time")
author = _("the pretix team")
version = version
description = _("This plugin adds the possibility to restrict the sale " +
"of a given product or variation to a certain timeframe " +
"or change its price during a certain period.")
def ready(self):
from . import signals # NOQA
default_app_config = 'pretix.plugins.timerestriction.TimeRestrictionApp'

View File

@@ -1,38 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import versions.models
from django.db import migrations, models
import pretix.base.models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '__first__'),
]
operations = [
migrations.CreateModel(
name='TimeRestriction',
fields=[
('id', models.CharField(serialize=False, max_length=36, primary_key=True)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(null=True, blank=True, default=None)),
('version_birth_date', models.DateTimeField()),
('timeframe_from', models.DateTimeField(verbose_name='Start of time frame')),
('timeframe_to', models.DateTimeField(verbose_name='End of time frame')),
('price', models.DecimalField(decimal_places=2, max_digits=7, null=True, blank=True, verbose_name='Price in time frame')),
('event', versions.models.VersionedForeignKey(verbose_name='Event', to='pretixbase.Event', related_name='restrictions_timerestriction_timerestriction')),
('item', versions.models.VersionedForeignKey(related_name='restrictions_timerestriction_timerestriction', null=True, verbose_name='Item', to='pretixbase.Item', blank=True)),
('variations', pretix.base.models.VariationsField(related_name='restrictions_timerestriction_timerestriction', blank=True, to='pretixbase.ItemVariation', verbose_name='Variations')),
],
options={
'verbose_name_plural': 'Restrictions',
'abstract': False,
'verbose_name': 'Restriction',
},
),
]

View File

@@ -1,24 +0,0 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import BaseRestriction
class TimeRestriction(BaseRestriction):
"""
This restriction makes an item or variation only available
within a given time frame. The price of the item can be modified
during this time frame.
"""
timeframe_from = models.DateTimeField(
verbose_name=_("Start of time frame"),
)
timeframe_to = models.DateTimeField(
verbose_name=_("End of time frame"),
)
price = models.DecimalField(
null=True, blank=True,
max_digits=7, decimal_places=2,
verbose_name=_("Price in time frame"),
)

View File

@@ -1,154 +0,0 @@
from django.dispatch import receiver
from django.forms.models import inlineformset_factory
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Item
from pretix.base.signals import determine_availability
from pretix.control.forms import RestrictionForm, RestrictionInlineFormset
from pretix.control.signals import restriction_formset
from .models import TimeRestriction
# The maximum validity of our cached values is the next date, one of our
# timeframe_from or tiemframe_to actions happens
def timediff(restrictions):
for r in restrictions:
if r.timeframe_from >= now():
yield (r.timeframe_from - now()).total_seconds()
if r.timeframe_to >= now():
yield (r.timeframe_to - now()).total_seconds()
@receiver(determine_availability, dispatch_uid="restriction_time")
def availability_handler(sender, **kwargs):
# Handle the signal's input arguments
item = kwargs['item']
variations = kwargs['variations']
cache = kwargs['cache']
context = kwargs['context'] # NOQA
# Fetch all restriction objects applied to this item
restrictions = list(TimeRestriction.objects.current.filter(
item=item,
).prefetch_related('variations'))
# If we do not know anything about this item, we are done here.
if len(restrictions) == 0:
return variations
# IMPORTANT:
# We need to make a two-level deep copy of the variations list before we
# modify it, becuase we need to to copy the dictionaries. Otherwise, we'll
# interfere with other plugins.
variations = [d.copy() for d in variations]
try:
cache_validity = min(timediff(restrictions))
except ValueError:
# empty sequence
# If we get here, there are restrictions available but nothing will
# change about them any more. If it were not for the case of no
# restriction for the base item but restrictions for special
# variations, we could quit here with 'item not available'.
cache_validity = 3600
# Walk through all variations we are asked for
for v in variations:
var_restrictions = []
for restriction in restrictions:
applied_to = list(restriction.variations.current.all())
# Only take this restriction into consideration if it
# is directly applied to this variation or if the item
# has no variations
if v.empty() or ('variation' in v and v['variation'] in applied_to):
var_restrictions.append(restriction)
if not var_restrictions:
v['available'] = True
v['price'] = None
continue
# If this point is reached, there ARE time restrictions for this item
# Therefore, it is only available inside one of the timeframes, but not
# without any timeframe.
available = False
# Make up some unique key for this variation
cachekey = 'timerestriction:%s:%s' % (
item.identity,
v.identify(),
)
# Fetch from cache, if available
cached = cache.get(cachekey)
if cached is not None:
v['available'] = (cached.split(":")[0] == 'True')
try:
v['price'] = float(cached.split(":")[1])
except ValueError:
v['price'] = None
continue
# Walk through all restriction objects applied to this item
prices = []
for restriction in var_restrictions:
if restriction.timeframe_from <= now() <= restriction.timeframe_to:
# Selling this item is currently possible
available = True
prices.append(restriction.price)
# Use the lowest of all prices set by restrictions
prices = [p for p in prices if p is not None]
price = min(prices) if prices else None
v['available'] = available
v['price'] = price
cache.set(
cachekey,
'%s:%s' % (
'True' if available else 'False',
str(price) if price else ''
),
cache_validity
)
return variations
class TimeRestrictionForm(RestrictionForm):
class Meta:
model = TimeRestriction
localized_fields = '__all__'
fields = [
'variations',
'timeframe_from',
'timeframe_to',
'price',
]
@receiver(restriction_formset, dispatch_uid="restriction_time_formset")
def formset_handler(sender, **kwargs):
formset = inlineformset_factory(
Item,
TimeRestriction,
formset=RestrictionInlineFormset,
form=TimeRestrictionForm,
can_order=False,
can_delete=True,
extra=0,
)
return {
'title': _('Restriction by time'),
'formsetclass': formset,
'prefix': 'timerestriction',
'description': 'If you use this restriction type, the system will only sell variations which are covered '
'by at least one of the timeframes you define below. You can also change the price of '
'variations for within the given timeframe. Please note, that if you change the price of '
'variations here, this will overrule the price set in the "Variations" section.'
}

View File

@@ -1,6 +1,7 @@
import sys
from django.db.models import Count
from django.db.models import Q, Count
from django.utils.timezone import now
from django.views.generic import TemplateView
from pretix.presale.views import CartMixin, EventViewMixin
@@ -13,7 +14,9 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
context = super().get_context_data(**kwargs)
# Fetch all items
items = self.request.event.items.all().filter(
active=True
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
).select_related(
'category', # for re-grouping
).prefetch_related(

View File

@@ -149,7 +149,6 @@ INSTALLED_APPS = [
'compressor',
'bootstrap3',
'djangoformsetjs',
'pretix.plugins.timerestriction',
'pretix.plugins.banktransfer',
'pretix.plugins.stripe',
'pretix.plugins.paypal',

View File

@@ -425,6 +425,40 @@ class ItemCategoryTest(TestCase):
assert c2 > c1
class ItemTest(TestCase):
"""
This test case tests various methods around the item model.
"""
@classmethod
def setUpTestData(cls):
o = Organizer.objects.create(name='Dummy', slug='dummy')
cls.event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
)
def test_is_available(self):
i = Item.objects.create(
event=self.event, name="Ticket", default_price=23,
active=True, available_until=now() + timedelta(days=1),
)
assert i.is_available()
i.available_from = now() - timedelta(days=1)
assert i.is_available()
i.available_from = now() + timedelta(days=1)
i.available_until = None
assert not i.is_available()
i.available_from = None
i.available_until = now() - timedelta(days=1)
assert not i.is_available()
i.available_from = None
i.available_until = None
assert i.is_available()
i.active = False
assert not i.is_available()
class CachedFileTestCase(TestCase):
def test_file_handling(self):
cf = CachedFile()

View File

@@ -4,7 +4,7 @@ from django.utils.timezone import now
from pretix.base.models import Event, Organizer
from pretix.base.plugins import get_all_plugins
from pretix.base.signals import determine_availability
from pretix.base.signals import register_ticket_outputs
class PluginRegistryTest(TestCase):
@@ -45,13 +45,13 @@ class PluginSignalTest(TestCase):
def test_no_plugins_active(self):
self.event.plugins = ''
self.event.save()
responses = determine_availability.send(self.event)
responses = register_ticket_outputs.send(self.event)
self.assertEqual(len(responses), 0)
def test_one_plugin_active(self):
self.event.plugins = 'tests.testdummy'
self.event.save()
payload = {'foo': 'bar'}
responses = determine_availability.send(self.event, **payload)
responses = register_ticket_outputs.send(self.event, **payload)
self.assertEqual(len(responses), 1)
self.assertIn('tests.testdummy.signals', [r[0].__module__ for r in responses])

View File

@@ -66,9 +66,9 @@ class EventsTest(BrowserTest):
def test_plugins(self):
self.driver.get('%s/control/event/%s/%s/settings/plugins' % (self.live_server_url, self.orga1.slug,
self.event1.slug))
self.assertIn("Restriction by time", self.driver.find_element_by_class_name("form-plugins").text)
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").text)
self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").click()
self.assertIn("Disable", self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").text)
self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").click()
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").text)
self.assertIn("Bank transfer", self.driver.find_element_by_class_name("form-plugins").text)
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)
self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").click()
self.assertIn("Disable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)
self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").click()
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)

View File

@@ -36,7 +36,6 @@ event_urls = [
"items/abc/",
"items/abc/variations",
"items/abc/properties",
"items/abc/restrictions",
"categories/",
"categories/add",
"categories/abc/",

View File

@@ -1,289 +0,0 @@
from datetime import timedelta
from django.test import TestCase
from django.utils.timezone import now
from pretix.base.models import (
Event, Item, ItemVariation, Organizer, Property, PropertyValue,
)
# Do NOT use relative imports here
from pretix.plugins.timerestriction import signals
from pretix.plugins.timerestriction.models import TimeRestriction
class TimeRestrictionTest(TestCase):
"""
This test case tests the various aspects of the time restriction
plugin
"""
@classmethod
def setUpTestData(cls):
o = Organizer.objects.create(name='Dummy', slug='dummy')
cls.event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
)
cls.item = Item.objects.create(event=cls.event, name='Dummy', default_price=14)
cls.property = Property.objects.create(event=cls.event, name='Size')
cls.value1 = PropertyValue.objects.create(prop=cls.property, value='S')
cls.value2 = PropertyValue.objects.create(prop=cls.property, value='M')
cls.value3 = PropertyValue.objects.create(prop=cls.property, value='L')
cls.variation1 = ItemVariation.objects.create(item=cls.item)
cls.variation1.values.add(cls.value1)
cls.variation2 = ItemVariation.objects.create(item=cls.item)
cls.variation2.values.add(cls.value2)
cls.variation3 = ItemVariation.objects.create(item=cls.item)
cls.variation3.values.add(cls.value3)
def test_nothing(self):
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertTrue('available' not in result[0] or result[0]['available'] is True)
def test_simple_case_available(self):
r = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=3),
event=self.event,
price=12
)
r.item = self.item
r.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_cached_result(self):
r = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=3),
event=self.event,
price=12
)
r.item = self.item
r.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_simple_case_unavailable(self):
r = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=3),
event=self.event,
price=12
)
r.item = self.item
r.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertFalse(result[0]['available'])
def test_multiple_overlapping_now(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=3),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=5),
event=self.event,
price=8
)
r2.item = self.item
r2.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 8)
def test_multiple_overlapping_tomorrow(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=5),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=1),
timeframe_to=now() + timedelta(days=7),
event=self.event,
price=8
)
r2.item = self.item
r2.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_multiple_distinct_available(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=2),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7),
event=self.event,
price=8
)
r2.item = self.item
r2.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_multiple_distinct_unavailable(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=1),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7),
event=self.event,
price=8
)
r2.item = self.item
r2.save()
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertFalse(result[0]['available'])
def test_variation_specific(self):
self.property.item = self.item
self.property.save()
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r1.variations.add(self.variation1)
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 3)
for v in result:
if 'variation' in v and v['variation'].pk == self.variation1.pk:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 12)
else:
self.assertTrue(v['available'])
def test_variation_specifics(self):
self.property.item = self.item
self.property.save()
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
event=self.event,
price=12
)
r1.item = self.item
r1.save()
r1.variations.add(self.variation1)
r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
event=self.event,
price=8
)
r2.item = self.item
r2.save()
r2.variations.add(self.variation1)
r3 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=1),
event=self.event,
price=8
)
r3.item = self.item
r3.save()
r3.variations.add(self.variation3)
result = signals.availability_handler(
self.event, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.event.get_cache()
)
self.assertEqual(len(result), 3)
for v in result:
if 'variation' in v and v['variation'].pk == self.variation1.pk:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 8)
elif 'variation' in v and v['variation'].pk == self.variation3.pk:
self.assertFalse(v['available'])
else:
self.assertTrue(v['available'])

View File

@@ -1,12 +1,10 @@
import datetime
import time
from datetime import timedelta
from bs4 import BeautifulSoup
from django.conf import settings
from django.test import TestCase
from django.utils.timezone import now
from tests.base import BrowserTest
from pretix.base.models import (
CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer,
@@ -158,6 +156,31 @@ class CartTest(CartTestMixin, TestCase):
self.assertIn('no longer available', doc.select('.alert-danger')[0].text)
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
def test_in_time_available(self):
self.ticket.available_until = now() + timedelta(days=2)
self.ticket.available_from = now() - timedelta(days=2)
self.ticket.save()
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_' + self.ticket.identity: '1',
}, follow=True)
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1)
def test_no_longer_available(self):
self.ticket.available_until = now() - timedelta(days=2)
self.ticket.save()
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_' + self.ticket.identity: '1',
}, follow=True)
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
def test_not_yet_available(self):
self.ticket.available_from = now() + timedelta(days=2)
self.ticket.save()
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_' + self.ticket.identity: '1',
}, follow=True)
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
def test_max_items(self):
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -262,34 +285,6 @@ class CartTest(CartTestMixin, TestCase):
self.assertIn('no longer available', doc.select('.alert-danger')[0].text)
self.assertFalse(CartPosition.objects.current.filter(identity=cp1.identity).exists())
def test_restriction_ok(self):
self.event.plugins = 'tests.testdummy'
self.event.save()
self.event.settings.testdummy_available = 'yes'
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_' + self.ticket.identity: '1',
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
objs = list(CartPosition.objects.current.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, 23)
def test_restriction_failed(self):
self.event.plugins = 'tests.testdummy'
self.event.save()
self.event.settings.testdummy_available = 'no'
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_' + self.ticket.identity: '1',
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content)
self.assertIn('no longer available', doc.select('.alert-danger')[0].text)
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
def test_remove_simple(self):
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,

View File

@@ -54,6 +54,31 @@ class ItemDisplayTest(EventTestMixin, BrowserTest):
self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug))
self.assertIn("Early-bird", self.driver.find_element_by_css_selector("section .product-row:first-child").text)
def test_timely_available(self):
q = Quota.objects.create(event=self.event, name='Quota', size=2)
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True,
available_until=now() + datetime.timedelta(days=2),
available_from=now() - datetime.timedelta(days=2))
q.items.add(item)
self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug))
self.assertIn("Early-bird", self.driver.find_element_by_css_selector("body").text)
def test_no_longer_available(self):
q = Quota.objects.create(event=self.event, name='Quota', size=2)
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True,
available_until=now() - datetime.timedelta(days=2))
q.items.add(item)
self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug))
self.assertNotIn("Early-bird", self.driver.find_element_by_css_selector("body").text)
def test_not_yet_available(self):
q = Quota.objects.create(event=self.event, name='Quota', size=2)
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True,
available_from=now() + datetime.timedelta(days=2))
q.items.add(item)
self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug))
self.assertNotIn("Early-bird", self.driver.find_element_by_css_selector("body").text)
def test_simple_with_category(self):
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
q = Quota.objects.create(event=self.event, name='Quota', size=2)

View File

@@ -1,18 +1,6 @@
from django.dispatch import receiver
from pretix.base.signals import determine_availability, register_ticket_outputs
@receiver(determine_availability, dispatch_uid="restriction_dummy")
def availability_handler(sender, **kwargs):
kwargs['sender'] = sender
if sender.settings.testdummy_available is not None:
variations = kwargs['variations']
variations = [d.copy() for d in variations]
for v in variations:
v['available'] = (sender.settings.testdummy_available == 'yes')
return variations
return []
from pretix.base.signals import register_ticket_outputs
@receiver(register_ticket_outputs, dispatch_uid="output_dummy")