forked from CGM_Public/pretix_original
Merge pull request #111 from pretix/remove-restrictions
Remove restrictions
This commit is contained in:
@@ -7,7 +7,6 @@ Contents:
|
|||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
plugins
|
plugins
|
||||||
restriction
|
|
||||||
payment
|
payment
|
||||||
ticketoutput
|
ticketoutput
|
||||||
exporter
|
exporter
|
||||||
|
|||||||
@@ -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/
|
|
||||||
@@ -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
|
additional information which can be entered by the user. Examples of possible questions
|
||||||
include 'Name' or 'age'.
|
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
|
Restriction by number
|
||||||
"""""""""""""""""""""
|
"""""""""""""""""""""
|
||||||
|
|
||||||
|
|||||||
24
src/pretix/base/migrations/0005_auto_20151206_1652.py
Normal file
24
src/pretix/base/migrations/0005_auto_20151206_1652.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,8 +2,8 @@ from .auth import User
|
|||||||
from .base import CachedFile, Versionable, cachedfile_name
|
from .base import CachedFile, Versionable, cachedfile_name
|
||||||
from .event import Event, EventLock, EventPermission, EventSetting
|
from .event import Event, EventLock, EventPermission, EventSetting
|
||||||
from .items import (
|
from .items import (
|
||||||
BaseRestriction, Item, ItemCategory, ItemVariation, Property,
|
Item, ItemCategory, ItemVariation, Property, PropertyValue, Question,
|
||||||
PropertyValue, Question, Quota, VariationsField, itempicture_upload_to,
|
Quota, VariationsField, itempicture_upload_to,
|
||||||
)
|
)
|
||||||
from .orders import (
|
from .orders import (
|
||||||
CachedTicket, CartPosition, ObjectWithAnswers, Order, OrderPosition,
|
CachedTicket, CartPosition, ObjectWithAnswers, Order, OrderPosition,
|
||||||
@@ -14,7 +14,7 @@ from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'Versionable', 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission',
|
'Versionable', 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission',
|
||||||
'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question',
|
'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',
|
'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to',
|
||||||
'generate_secret'
|
'generate_secret'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
|
||||||
from itertools import product
|
from itertools import product
|
||||||
|
|
||||||
from django.db import models
|
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.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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 versions.models import VersionedForeignKey, VersionedManyToManyField
|
||||||
|
|
||||||
from pretix.base.i18n import I18nCharField, I18nTextField
|
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.
|
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.
|
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.
|
:param event: The event this belongs to.
|
||||||
:type event: Event
|
:type event: Event
|
||||||
:param category: The category this belongs to. May be null.
|
:param category: The category this belongs to. May be null.
|
||||||
@@ -100,6 +97,10 @@ class Item(Versionable):
|
|||||||
:type admission: bool
|
:type admission: bool
|
||||||
:param picture: A product picture to be shown next to the product description.
|
:param picture: A product picture to be shown next to the product description.
|
||||||
:type picture: File
|
: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,
|
null=True, blank=True,
|
||||||
upload_to=itempicture_upload_to
|
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:
|
class Meta:
|
||||||
verbose_name = _("Product")
|
verbose_name = _("Product")
|
||||||
@@ -173,6 +184,19 @@ class Item(Versionable):
|
|||||||
if self.event:
|
if self.event:
|
||||||
self.event.get_cache().clear()
|
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]:
|
def get_all_variations(self, use_cache: bool=False) -> List[VariationDict]:
|
||||||
"""
|
"""
|
||||||
This method returns a list containing all variations of this
|
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):
|
def get_all_available_variations(self, use_cache: bool=False):
|
||||||
"""
|
"""
|
||||||
This method returns a list of all variations which are theoretically
|
This method returns a list of all variations which are theoretically
|
||||||
possible for sale. It DOES call all activated restriction plugins, and it
|
possible for sale. It DOES only return variations which DO have an ItemVariation
|
||||||
DOES only return variations which DO have an ItemVariation object, as all
|
object, as all variations without one CAN NOT be part of a Quota and therefore can
|
||||||
variations without one CAN NOT be part of a Quota and therefore can never
|
never be available for sale. The only exception is the empty variation
|
||||||
be available for sale. The only exception is the empty variation
|
|
||||||
for items without properties, which never has an ItemVariation object.
|
for items without properties, which never has an ItemVariation object.
|
||||||
|
|
||||||
This DOES NOT take into account quotas itself. Use ``is_available`` on the
|
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'):
|
if use_cache and hasattr(self, '_get_all_available_variations_cache'):
|
||||||
return 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()
|
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):
|
for i, var in enumerate(variations):
|
||||||
var['available'] = var['variation'].active if 'variation' in var else True
|
var['available'] = var['variation'].active if 'variation' in var else True
|
||||||
if 'variation' in var:
|
if 'variation' in var:
|
||||||
if var['variation'].default_price:
|
if var['variation'].default_price is not None:
|
||||||
var['price'] = var['variation'].default_price
|
var['price'] = var['variation'].default_price
|
||||||
else:
|
else:
|
||||||
var['price'] = self.default_price
|
var['price'] = self.default_price
|
||||||
else:
|
else:
|
||||||
var['price'] = self.default_price
|
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']]
|
variations = [var for var in variations if var['available']]
|
||||||
|
|
||||||
self._get_all_available_variations_cache = variations
|
self._get_all_available_variations_cache = variations
|
||||||
@@ -322,38 +324,6 @@ class Item(Versionable):
|
|||||||
return min([q.availability() for q in self.quotas.all()],
|
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))
|
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):
|
class Property(Versionable):
|
||||||
"""
|
"""
|
||||||
@@ -466,8 +436,6 @@ class ItemVariation(Versionable):
|
|||||||
values by creating an ItemVariation object for them with active set to
|
values by creating an ItemVariation object for them with active set to
|
||||||
False.
|
False.
|
||||||
|
|
||||||
Restrictions can be not only set to items but also directly to variations.
|
|
||||||
|
|
||||||
:param item: The item this variation belongs to
|
:param item: The item this variation belongs to
|
||||||
:type item: Item
|
:type item: Item
|
||||||
:param values: A set of ``PropertyValue`` objects defining this variation
|
:param values: A set of ``PropertyValue`` objects defining this variation
|
||||||
@@ -531,31 +499,6 @@ class ItemVariation(Versionable):
|
|||||||
vd['variation'] = self
|
vd['variation'] = self
|
||||||
return vd
|
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):
|
def add_values_from_string(self, pk):
|
||||||
"""
|
"""
|
||||||
Add values to this ItemVariation using a serialized string of the form
|
Add values to this ItemVariation using a serialized string of the form
|
||||||
@@ -672,47 +615,6 @@ class Question(Versionable):
|
|||||||
self.event.get_cache().clear()
|
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):
|
class Quota(Versionable):
|
||||||
"""
|
"""
|
||||||
A quota is a "pool of tickets". It is there to limit the number of items
|
A quota is a "pool of tickets". It is there to limit the number of items
|
||||||
|
|||||||
@@ -90,15 +90,10 @@ def _add_items(event: Event, items: List[Tuple[str, Optional[str], int]],
|
|||||||
item = items_cache[i[0]]
|
item = items_cache[i[0]]
|
||||||
variation = variations_cache[i[1]] if i[1] is not None else None
|
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.
|
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||||
|
|
||||||
if 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']
|
err = err or error_messages['unavailable']
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -121,11 +116,15 @@ def _add_items(event: Event, items: List[Tuple[str, Optional[str], int]],
|
|||||||
# Recreating
|
# Recreating
|
||||||
cp = i[3].clone()
|
cp = i[3].clone()
|
||||||
cp.expires = expiry
|
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()
|
cp.save()
|
||||||
else:
|
else:
|
||||||
CartPosition.objects.create(
|
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
|
cart_id=cart_id
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]):
|
|||||||
if cp.expires >= dt:
|
if cp.expires >= dt:
|
||||||
# Other checks are not necessary
|
# Other checks are not necessary
|
||||||
continue
|
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:
|
if price is False or len(quotas) == 0:
|
||||||
err = err or error_messages['unavailable']
|
err = err or error_messages['unavailable']
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
|||||||
@@ -46,15 +46,6 @@ class EventPluginSignal(django.dispatch.Signal):
|
|||||||
responses.append((receiver, response))
|
responses.append((receiver, response))
|
||||||
return responses
|
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
|
This signal is sent out to get all known payment providers. Receivers should return a
|
||||||
subclass of pretix.base.payment.BasePaymentProvider
|
subclass of pretix.base.payment.BasePaymentProvider
|
||||||
|
|||||||
@@ -99,60 +99,6 @@ class TolerantFormsetModelForm(VersionedModelForm):
|
|||||||
return False
|
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):
|
def selector(values, prop):
|
||||||
# Given an iterable of PropertyValue objects, this will return a
|
# Given an iterable of PropertyValue objects, this will return a
|
||||||
# list of their primary keys, ordered by the primary keys of the
|
# list of their primary keys, ordered by the primary keys of the
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ class ItemFormGeneral(VersionedModelForm):
|
|||||||
'picture',
|
'picture',
|
||||||
'default_price',
|
'default_price',
|
||||||
'tax_rate',
|
'tax_rate',
|
||||||
|
'available_from',
|
||||||
|
'available_until',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" == 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.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.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>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1>{% trans "Create product" %}</h1>
|
<h1>{% trans "Create product" %}</h1>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
{% bootstrap_field form.default_price layout="horizontal" %}
|
{% bootstrap_field form.default_price layout="horizontal" %}
|
||||||
{% bootstrap_field form.tax_rate layout="horizontal" %}
|
{% bootstrap_field form.tax_rate layout="horizontal" %}
|
||||||
</fieldset>
|
</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">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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-]+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
|
||||||
url(r'^items/(?P<item>[0-9a-f-]+)/variations$', item.ItemVariations.as_view(),
|
url(r'^items/(?P<item>[0-9a-f-]+)/variations$', item.ItemVariations.as_view(),
|
||||||
name='event.item.variations'),
|
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(),
|
url(r'^items/(?P<item>[0-9a-f-]+)/properties$', item.ItemProperties.as_view(),
|
||||||
name='event.item.properties'),
|
name='event.item.properties'),
|
||||||
url(r'^items/(?P<item>[0-9a-f-]+)/up$', item.item_move_up, name='event.items.up'),
|
url(r'^items/(?P<item>[0-9a-f-]+)/up$', item.item_move_up, name='event.items.up'),
|
||||||
|
|||||||
@@ -740,68 +740,6 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
|
|||||||
return context
|
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):
|
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
|
||||||
model = Item
|
model = Item
|
||||||
template_name = 'pretixcontrol/item/delete.html'
|
template_name = 'pretixcontrol/item/delete.html'
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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"),
|
|
||||||
)
|
|
||||||
@@ -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.'
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import sys
|
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 django.views.generic import TemplateView
|
||||||
|
|
||||||
from pretix.presale.views import CartMixin, EventViewMixin
|
from pretix.presale.views import CartMixin, EventViewMixin
|
||||||
@@ -13,7 +14,9 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
|
|||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
# Fetch all items
|
# Fetch all items
|
||||||
items = self.request.event.items.all().filter(
|
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(
|
).select_related(
|
||||||
'category', # for re-grouping
|
'category', # for re-grouping
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ INSTALLED_APPS = [
|
|||||||
'compressor',
|
'compressor',
|
||||||
'bootstrap3',
|
'bootstrap3',
|
||||||
'djangoformsetjs',
|
'djangoformsetjs',
|
||||||
'pretix.plugins.timerestriction',
|
|
||||||
'pretix.plugins.banktransfer',
|
'pretix.plugins.banktransfer',
|
||||||
'pretix.plugins.stripe',
|
'pretix.plugins.stripe',
|
||||||
'pretix.plugins.paypal',
|
'pretix.plugins.paypal',
|
||||||
|
|||||||
@@ -425,6 +425,40 @@ class ItemCategoryTest(TestCase):
|
|||||||
assert c2 > c1
|
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):
|
class CachedFileTestCase(TestCase):
|
||||||
def test_file_handling(self):
|
def test_file_handling(self):
|
||||||
cf = CachedFile()
|
cf = CachedFile()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.utils.timezone import now
|
|||||||
|
|
||||||
from pretix.base.models import Event, Organizer
|
from pretix.base.models import Event, Organizer
|
||||||
from pretix.base.plugins import get_all_plugins
|
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):
|
class PluginRegistryTest(TestCase):
|
||||||
@@ -45,13 +45,13 @@ class PluginSignalTest(TestCase):
|
|||||||
def test_no_plugins_active(self):
|
def test_no_plugins_active(self):
|
||||||
self.event.plugins = ''
|
self.event.plugins = ''
|
||||||
self.event.save()
|
self.event.save()
|
||||||
responses = determine_availability.send(self.event)
|
responses = register_ticket_outputs.send(self.event)
|
||||||
self.assertEqual(len(responses), 0)
|
self.assertEqual(len(responses), 0)
|
||||||
|
|
||||||
def test_one_plugin_active(self):
|
def test_one_plugin_active(self):
|
||||||
self.event.plugins = 'tests.testdummy'
|
self.event.plugins = 'tests.testdummy'
|
||||||
self.event.save()
|
self.event.save()
|
||||||
payload = {'foo': 'bar'}
|
payload = {'foo': 'bar'}
|
||||||
responses = determine_availability.send(self.event, **payload)
|
responses = register_ticket_outputs.send(self.event, **payload)
|
||||||
self.assertEqual(len(responses), 1)
|
self.assertEqual(len(responses), 1)
|
||||||
self.assertIn('tests.testdummy.signals', [r[0].__module__ for r in responses])
|
self.assertIn('tests.testdummy.signals', [r[0].__module__ for r in responses])
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ class EventsTest(BrowserTest):
|
|||||||
def test_plugins(self):
|
def test_plugins(self):
|
||||||
self.driver.get('%s/control/event/%s/%s/settings/plugins' % (self.live_server_url, self.orga1.slug,
|
self.driver.get('%s/control/event/%s/%s/settings/plugins' % (self.live_server_url, self.orga1.slug,
|
||||||
self.event1.slug))
|
self.event1.slug))
|
||||||
self.assertIn("Restriction by time", self.driver.find_element_by_class_name("form-plugins").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.timerestriction").text)
|
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)
|
||||||
self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").click()
|
self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").click()
|
||||||
self.assertIn("Disable", self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").text)
|
self.assertIn("Disable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)
|
||||||
self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").click()
|
self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").click()
|
||||||
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.timerestriction").text)
|
self.assertIn("Enable", self.driver.find_element_by_name("plugin:pretix.plugins.banktransfer").text)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ event_urls = [
|
|||||||
"items/abc/",
|
"items/abc/",
|
||||||
"items/abc/variations",
|
"items/abc/variations",
|
||||||
"items/abc/properties",
|
"items/abc/properties",
|
||||||
"items/abc/restrictions",
|
|
||||||
"categories/",
|
"categories/",
|
||||||
"categories/add",
|
"categories/add",
|
||||||
"categories/abc/",
|
"categories/abc/",
|
||||||
|
|||||||
@@ -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'])
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from tests.base import BrowserTest
|
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer,
|
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.assertIn('no longer available', doc.select('.alert-danger')[0].text)
|
||||||
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
|
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):
|
def test_max_items(self):
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
@@ -262,34 +285,6 @@ class CartTest(CartTestMixin, TestCase):
|
|||||||
self.assertIn('no longer available', doc.select('.alert-danger')[0].text)
|
self.assertIn('no longer available', doc.select('.alert-danger')[0].text)
|
||||||
self.assertFalse(CartPosition.objects.current.filter(identity=cp1.identity).exists())
|
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):
|
def test_remove_simple(self):
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
|
|||||||
@@ -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.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)
|
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):
|
def test_simple_with_category(self):
|
||||||
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
||||||
q = Quota.objects.create(event=self.event, name='Quota', size=2)
|
q = Quota.objects.create(event=self.event, name='Quota', size=2)
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from pretix.base.signals import determine_availability, register_ticket_outputs
|
from pretix.base.signals import 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 []
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_ticket_outputs, dispatch_uid="output_dummy")
|
@receiver(register_ticket_outputs, dispatch_uid="output_dummy")
|
||||||
|
|||||||
Reference in New Issue
Block a user