forked from CGM_Public/pretix_original
@@ -6,6 +6,7 @@ api
|
|||||||
auditability
|
auditability
|
||||||
auth
|
auth
|
||||||
autobuild
|
autobuild
|
||||||
|
availabilities
|
||||||
backend
|
backend
|
||||||
backends
|
backends
|
||||||
banktransfer
|
banktransfer
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.core.files.storage import default_storage
|
|||||||
from django.core.mail import get_connection
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||||
from django.template.defaultfilters import date as _date
|
from django.template.defaultfilters import date as _date
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.validators import EventSlugBlacklistValidator
|
from pretix.base.validators import EventSlugBlacklistValidator
|
||||||
|
from pretix.helpers.database import GroupConcat
|
||||||
from pretix.helpers.daterange import daterange
|
from pretix.helpers.daterange import daterange
|
||||||
from pretix.helpers.json import safe_string
|
from pretix.helpers.json import safe_string
|
||||||
|
|
||||||
@@ -159,6 +160,79 @@ class EventMixin:
|
|||||||
|
|
||||||
return safe_string(json.dumps(eventdict))
|
return safe_string(json.dumps(eventdict))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def annotated(cls, qs, channel='web'):
|
||||||
|
from pretix.base.models import Item, ItemVariation, Quota
|
||||||
|
|
||||||
|
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||||
|
Q(variations__isnull=True)
|
||||||
|
& Q(quotas__pk=OuterRef('pk'))
|
||||||
|
).order_by().values_list('quotas__pk').annotate(
|
||||||
|
items=GroupConcat('pk', delimiter=',')
|
||||||
|
).values('items')
|
||||||
|
sq_active_variation = ItemVariation.objects.filter(
|
||||||
|
Q(active=True)
|
||||||
|
& Q(item__active=True)
|
||||||
|
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||||
|
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||||
|
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||||
|
& Q(item__sales_channels__contains=channel)
|
||||||
|
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
|
||||||
|
& Q(quotas__pk=OuterRef('pk'))
|
||||||
|
).order_by().values_list('quotas__pk').annotate(
|
||||||
|
items=GroupConcat('pk', delimiter=',')
|
||||||
|
).values('items')
|
||||||
|
return qs.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'quotas',
|
||||||
|
to_attr='active_quotas',
|
||||||
|
queryset=Quota.objects.annotate(
|
||||||
|
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||||
|
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||||
|
).exclude(
|
||||||
|
Q(active_items="") & Q(active_variations="")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def best_availability_state(self):
|
||||||
|
from .items import Quota
|
||||||
|
|
||||||
|
if not hasattr(self, 'active_quotas'):
|
||||||
|
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||||
|
items_available = set()
|
||||||
|
vars_available = set()
|
||||||
|
items_reserved = set()
|
||||||
|
vars_reserved = set()
|
||||||
|
items_gone = set()
|
||||||
|
vars_gone = set()
|
||||||
|
for q in self.active_quotas:
|
||||||
|
res = q.availability(allow_cache=True)
|
||||||
|
|
||||||
|
if res[0] == Quota.AVAILABILITY_OK:
|
||||||
|
if q.active_items:
|
||||||
|
items_available.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_available.update(q.active_variations.split(","))
|
||||||
|
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||||
|
if q.active_items:
|
||||||
|
items_reserved.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_available.update(q.active_variations.split(","))
|
||||||
|
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||||
|
if q.active_items:
|
||||||
|
items_gone.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_gone.update(q.active_variations.split(","))
|
||||||
|
if not self.active_quotas:
|
||||||
|
return None
|
||||||
|
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||||
|
return Quota.AVAILABILITY_OK
|
||||||
|
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||||
|
return Quota.AVAILABILITY_RESERVED
|
||||||
|
return Quota.AVAILABILITY_GONE
|
||||||
|
|
||||||
|
|
||||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||||
class Event(EventMixin, LoggedModel):
|
class Event(EventMixin, LoggedModel):
|
||||||
@@ -572,8 +646,10 @@ class Event(EventMixin, LoggedModel):
|
|||||||
)
|
)
|
||||||
).order_by('date_from', 'name')
|
).order_by('date_from', 'name')
|
||||||
|
|
||||||
@property
|
def subevents_annotated(self, channel):
|
||||||
def subevent_list_subevents(self):
|
return SubEvent.annotated(self.subevents, channel)
|
||||||
|
|
||||||
|
def subevents_sorted(self, queryset):
|
||||||
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||||
orderfields = {
|
orderfields = {
|
||||||
'date_ascending': ('date_from', 'name'),
|
'date_ascending': ('date_from', 'name'),
|
||||||
@@ -581,7 +657,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
'name_ascending': ('name', 'date_from'),
|
'name_ascending': ('name', 'date_from'),
|
||||||
'name_descending': ('-name', 'date_from'),
|
'name_descending': ('-name', 'date_from'),
|
||||||
}[ordering]
|
}[ordering]
|
||||||
subevs = self.subevents.filter(
|
subevs = queryset.filter(
|
||||||
Q(active=True) & (
|
Q(active=True) & (
|
||||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||||
| Q(date_to__gte=now())
|
| Q(date_to__gte=now())
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ class SubEventItemVariation(models.Model):
|
|||||||
self.subevent.event.cache.clear()
|
self.subevent.event.cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemQuerySet(models.QuerySet):
|
||||||
|
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||||
|
q = (
|
||||||
|
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||||
|
# in models/event.py: EventMixin.annotated()
|
||||||
|
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()))
|
||||||
|
& Q(sales_channels__contains=channel)
|
||||||
|
)
|
||||||
|
if not allow_addons:
|
||||||
|
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||||
|
qs = self.filter(q)
|
||||||
|
|
||||||
|
vouchq = Q(hide_without_voucher=False)
|
||||||
|
if voucher:
|
||||||
|
if voucher.item_id:
|
||||||
|
vouchq |= Q(pk=voucher.item_id)
|
||||||
|
qs = qs.filter(pk=voucher.item_id)
|
||||||
|
elif voucher.quota_id:
|
||||||
|
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||||
|
return qs.filter(vouchq)
|
||||||
|
|
||||||
|
|
||||||
class Item(LoggedModel):
|
class Item(LoggedModel):
|
||||||
"""
|
"""
|
||||||
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.
|
||||||
@@ -200,6 +224,8 @@ class Item(LoggedModel):
|
|||||||
:type sales_channels: bool
|
:type sales_channels: bool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = ItemQuerySet.as_manager()
|
||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -930,6 +956,16 @@ class Quota(LoggedModel):
|
|||||||
:type size: int
|
:type size: int
|
||||||
:param items: The set of :py:class:`Item` objects this quota applies to
|
:param items: The set of :py:class:`Item` objects this quota applies to
|
||||||
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
||||||
|
|
||||||
|
This model keeps a cache of the quota availability that is used in places where up-to-date
|
||||||
|
data is not important. This cache might be out of date even though a more recent quota was
|
||||||
|
calculated. This is intentional to keep database writes low. Currently, the cached values
|
||||||
|
are written whenever the quota is being calculated throughout the system and the cache is
|
||||||
|
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
|
||||||
|
(i.e. more free quota).
|
||||||
|
|
||||||
|
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
|
||||||
|
the event that is newer than the quota's cached time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
AVAILABILITY_GONE = 0
|
AVAILABILITY_GONE = 0
|
||||||
@@ -1012,6 +1048,15 @@ class Quota(LoggedModel):
|
|||||||
This method is used to determine whether Items or ItemVariations belonging
|
This method is used to determine whether Items or ItemVariations belonging
|
||||||
to this quota should currently be available for sale.
|
to this quota should currently be available for sale.
|
||||||
|
|
||||||
|
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
|
||||||
|
to ``True``.
|
||||||
|
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
|
||||||
|
contained in that dictionary, this value will be used. Otherwise, the dict
|
||||||
|
will be populated accordingly.
|
||||||
|
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
|
||||||
|
the documentation of this model class. Only works if ``count_waitinglist`` is
|
||||||
|
set to ``True``.
|
||||||
|
|
||||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||||
and the second is the number of available tickets.
|
and the second is the number of available tickets.
|
||||||
"""
|
"""
|
||||||
@@ -1027,7 +1072,10 @@ class Quota(LoggedModel):
|
|||||||
res = self._availability(now_dt, count_waitinglist)
|
res = self._availability(now_dt, count_waitinglist)
|
||||||
|
|
||||||
self.event.cache.delete('item_quota_cache')
|
self.event.cache.delete('item_quota_cache')
|
||||||
if count_waitinglist and not self.cache_is_hot(now_dt):
|
rewrite_cache = count_waitinglist and (
|
||||||
|
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||||
|
)
|
||||||
|
if rewrite_cache:
|
||||||
self.cached_availability_state = res[0]
|
self.cached_availability_state = res[0]
|
||||||
self.cached_availability_number = res[1]
|
self.cached_availability_number = res[1]
|
||||||
self.cached_availability_time = now_dt
|
self.cached_availability_time = now_dt
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, Max, OuterRef, Q, Subquery
|
from django.db.models import F, Max, OuterRef, Q, Subquery
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from pretix.base.models import LogEntry, Quota
|
from pretix.base.models import LogEntry, Quota
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
@@ -26,7 +29,8 @@ def refresh_quota_caches():
|
|||||||
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
||||||
).filter(
|
).filter(
|
||||||
Q(cached_availability_time__isnull=True) |
|
Q(cached_availability_time__isnull=True) |
|
||||||
Q(cached_availability_time__lt=F('last_activity'))
|
Q(cached_availability_time__lt=F('last_activity')) |
|
||||||
|
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
|
||||||
)
|
)
|
||||||
for q in quotas:
|
for q in quotas:
|
||||||
q.availability()
|
q.availability()
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ DEFAULTS = {
|
|||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool
|
||||||
},
|
},
|
||||||
|
'event_list_availability': {
|
||||||
|
'default': 'True',
|
||||||
|
'type': bool
|
||||||
|
},
|
||||||
'event_list_type': {
|
'event_list_type': {
|
||||||
'default': 'list',
|
'default': 'list',
|
||||||
'type': str
|
'type': str
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
|||||||
('calendar', _('Calendar'))
|
('calendar', _('Calendar'))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
event_list_availability = forms.BooleanField(
|
||||||
|
label=_('Show availability in event overviews'),
|
||||||
|
help_text=_('If checked, the list of events will show if events are sold out. This might '
|
||||||
|
'make for longer page loading times if you have lots of events and the shown status might be out '
|
||||||
|
'of date for up to two minutes.'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
organizer_link_back = forms.BooleanField(
|
organizer_link_back = forms.BooleanField(
|
||||||
label=_('Link back to organizer overview on all event pages'),
|
label=_('Link back to organizer overview on all event pages'),
|
||||||
required=False
|
required=False
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{% bootstrap_field form.organizer_logo_image layout="control" %}
|
{% bootstrap_field form.organizer_logo_image layout="control" %}
|
||||||
{% bootstrap_field form.organizer_homepage_text layout="control" %}
|
{% bootstrap_field form.organizer_homepage_text layout="control" %}
|
||||||
{% bootstrap_field form.event_list_type layout="control" %}
|
{% bootstrap_field form.event_list_type layout="control" %}
|
||||||
|
{% bootstrap_field form.event_list_availability layout="control" %}
|
||||||
{% bootstrap_field form.organizer_link_back layout="control" %}
|
{% bootstrap_field form.organizer_link_back layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import Aggregate
|
||||||
from django.db.models.expressions import OrderBy
|
from django.db.models.expressions import OrderBy
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +58,21 @@ class FixedOrderBy(OrderBy):
|
|||||||
template = template or self.template
|
template = template or self.template
|
||||||
params = params * template.count('%(expression)s')
|
params = params * template.count('%(expression)s')
|
||||||
return (template % placeholders).rstrip(), params
|
return (template % placeholders).rstrip(), params
|
||||||
|
|
||||||
|
|
||||||
|
class GroupConcat(Aggregate):
|
||||||
|
function = 'group_concat'
|
||||||
|
template = '%(function)s(%(field)s, "%(separator)s")'
|
||||||
|
|
||||||
|
def __init__(self, *expressions, **extra):
|
||||||
|
if 'separator' not in extra:
|
||||||
|
# For PostgreSQL separator is an obligatory
|
||||||
|
extra.update({'separator': ','})
|
||||||
|
super().__init__(*expressions, **extra)
|
||||||
|
|
||||||
|
def as_postgresql(self, compiler, connection):
|
||||||
|
return super().as_sql(
|
||||||
|
compiler, connection,
|
||||||
|
function='string_agg',
|
||||||
|
template="%(function)s(%(field)s::text, '%(separator)s')",
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ from itertools import chain
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Count, Prefetch, Q
|
from django.db.models import Count, Prefetch
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
from django.utils.formats import number_format
|
from django.utils.formats import number_format
|
||||||
from django.utils.timezone import now
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.forms.questions import (
|
from pretix.base.forms.questions import (
|
||||||
@@ -204,12 +203,9 @@ class AddOnsForm(forms.Form):
|
|||||||
ckey = '{}-{}'.format(subevent.pk if subevent else 0, category.pk)
|
ckey = '{}-{}'.format(subevent.pk if subevent else 0, category.pk)
|
||||||
if ckey not in item_cache:
|
if ckey not in item_cache:
|
||||||
# Get all items to possibly show
|
# Get all items to possibly show
|
||||||
items = category.items.filter(
|
items = category.items.filter_available(
|
||||||
Q(active=True)
|
channel=self.sales_channel,
|
||||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
allow_addons=True
|
||||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
|
||||||
& Q(hide_without_voucher=False)
|
|
||||||
& Q(sales_channels__contains=self.sales_channel)
|
|
||||||
).select_related('tax_rule').prefetch_related(
|
).select_related('tax_rule').prefetch_related(
|
||||||
Prefetch('quotas',
|
Prefetch('quotas',
|
||||||
to_attr='_subevent_quotas',
|
to_attr='_subevent_quotas',
|
||||||
|
|||||||
@@ -38,4 +38,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% include "pretixpresale/fragment_calendar.html" %}
|
{% include "pretixpresale/fragment_calendar.html" with show_avail=event.settings.event_list_availability %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load eventurl %}
|
{% load eventurl %}
|
||||||
{% for subev in event.subevent_list_subevents %}
|
{% for subev in subevent_list %}
|
||||||
<a href="{% eventurl event "presale:event.index" subevent=subev.id cart_namespace=cart_namespace %}"
|
<a href="{% eventurl event "presale:event.index" subevent=subev.id cart_namespace=cart_namespace %}"
|
||||||
class="subevent-row">
|
class="subevent-row">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -16,7 +16,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 text-right">
|
<div class="col-md-2 text-right">
|
||||||
{% if subev.presale_is_running %}
|
{% if subev.presale_is_running and event.settings.event_list_availability %}
|
||||||
|
{% if subev.best_availability_state == 100 %}
|
||||||
|
<span class="label label-success">{% trans "Tickets on sale" %}</span>
|
||||||
|
{% elif event.settings.waiting_list_enabled and subev.best_availability_state >= 0 %}
|
||||||
|
<span class="label label-warning">{% trans "Waiting list" %}</span>
|
||||||
|
{% elif subev.best_availability_state == 20 %}
|
||||||
|
<span class="label label-warning">{% trans "Reserved" %}</span>
|
||||||
|
{% elif subev.best_availability_state < 20 %}
|
||||||
|
<span class="label label-danger">{% trans "Sold out" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif subev.presale_is_running %}
|
||||||
<span class="label label-success">{% trans "Tickets on sale" %}</span>
|
<span class="label label-success">{% trans "Tickets on sale" %}</span>
|
||||||
{% elif subev.presale_has_ended %}
|
{% elif subev.presale_has_ended %}
|
||||||
<span class="label label-danger">{% trans "Sale over" %}</span>
|
<span class="label label-danger">{% trans "Sale over" %}</span>
|
||||||
|
|||||||
@@ -38,7 +38,17 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="event-status">
|
<span class="event-status">
|
||||||
{% if event.event.presale_is_running %}
|
{% if event.event.presale_is_running and show_avail %}
|
||||||
|
{% if event.event.best_availability_state == 100 %}
|
||||||
|
<span class="fa fa-ticket"></span> {% trans "Tickets on sale" %}
|
||||||
|
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
|
||||||
|
<span class="fa fa-ticket"></span> {% trans "Waiting list" %}
|
||||||
|
{% elif event.event.best_availability_state == 20 %}
|
||||||
|
<span class="fa fa-ticket"></span> {% trans "Reserved" %}
|
||||||
|
{% elif event.event.best_availability_state < 20 %}
|
||||||
|
<span class="fa fa-ticket"></span> {% trans "Sold out" %}
|
||||||
|
{% endif %}
|
||||||
|
{% elif event.event.presale_is_running %}
|
||||||
<span class="fa fa-ticket"></span> {% trans "Tickets on sale" %}
|
<span class="fa fa-ticket"></span> {% trans "Tickets on sale" %}
|
||||||
{% elif event.event.presale_has_ended %}
|
{% elif event.event.presale_has_ended %}
|
||||||
<span class="fa fa-ticket"></span> {% trans "Sale over" %}
|
<span class="fa fa-ticket"></span> {% trans "Sale over" %}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% include "pretixpresale/fragment_calendar.html" %}
|
{% include "pretixpresale/fragment_calendar.html" with show_avail=request.organizer.settings.event_list_availability %}
|
||||||
|
|
||||||
{% if multiple_timezones %}
|
{% if multiple_timezones %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
|
|||||||
@@ -45,19 +45,50 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
<th>{% trans "Date" %}</th>
|
<th>{% trans "Date" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for e in events %}{% eventurl e "presale:event.index" as url %}
|
{% for e in events %}{% eventurl e "presale:event.index" as url %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url }}">{{ e.name }}</a></td>
|
<td>
|
||||||
|
<a href="{{ url }}">{{ e.name }}</a>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ e.daterange|default:e.get_date_range_display }}
|
{{ e.daterange|default:e.get_date_range_display }}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if e.has_subevents %}
|
||||||
|
<span class="label label-default">{% trans "Event series" %}</span>
|
||||||
|
{% elif e.presale_is_running and request.organizer.settings.event_list_availability %}
|
||||||
|
{% if e.best_availability_state == 100 %}
|
||||||
|
<span class="label label-success">{% trans "Tickets on sale" %}</span>
|
||||||
|
{% elif e.settings.waiting_list_enabled and e.best_availability_state >= 0 %}
|
||||||
|
<span class="label label-warning">{% trans "Waiting list" %}</span>
|
||||||
|
{% elif e.best_availability_state == 20 %}
|
||||||
|
<span class="label label-warning">{% trans "Reserved" %}</span>
|
||||||
|
{% elif e.best_availability_state < 20 %}
|
||||||
|
<span class="label label-danger">{% trans "Sold out" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.presale_is_running %}
|
||||||
|
<span class="label label-success">{% trans "Tickets on sale" %}</span>
|
||||||
|
{% elif e.presale_has_ended %}
|
||||||
|
<span class="label label-danger">{% trans "Sale over" %}</span>
|
||||||
|
{% elif e.settings.presale_start_show_date %}
|
||||||
|
<span class="label label-warning">
|
||||||
|
{% blocktrans trimmed with date=subev.presale_start|date:"SHORT_DATE_FORMAT" %}
|
||||||
|
Sale starts {{ date }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="label label-warning">{% trans "Not yet on sale" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<a class="btn btn-primary" href="{{ url }}">
|
<a class="btn btn-primary" href="{{ url }}">
|
||||||
{% if e.presale_is_running %}{% trans "Buy tickets" %}
|
{% if e.has_subevents %}{% trans "Buy tickets" %}
|
||||||
|
{% elif e.presale_is_running and e.best_availability_state == 100 %}{% trans "Buy tickets" %}
|
||||||
{% else %}{% trans "More info" %}
|
{% else %}{% trans "More info" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from importlib import import_module
|
|||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import Count, Prefetch, Q
|
from django.db.models import Count, Prefetch
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@@ -48,23 +48,7 @@ def item_group_by_category(items):
|
|||||||
|
|
||||||
|
|
||||||
def get_grouped_items(event, subevent=None, voucher=None, channel='web'):
|
def get_grouped_items(event, subevent=None, voucher=None, channel='web'):
|
||||||
items = event.items.all().filter(
|
items = event.items.filter_available(channel=channel, voucher=voucher).select_related(
|
||||||
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()))
|
|
||||||
& Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
|
||||||
& Q(sales_channels__contains=channel)
|
|
||||||
)
|
|
||||||
|
|
||||||
vouchq = Q(hide_without_voucher=False)
|
|
||||||
if voucher:
|
|
||||||
if voucher.item_id:
|
|
||||||
vouchq |= Q(pk=voucher.item_id)
|
|
||||||
items = items.filter(pk=voucher.item_id)
|
|
||||||
elif voucher.quota_id:
|
|
||||||
items = items.filter(quotas__in=[voucher.quota_id])
|
|
||||||
|
|
||||||
items = items.filter(vouchq).select_related(
|
|
||||||
'category', 'tax_rule', # for re-grouping
|
'category', 'tax_rule', # for re-grouping
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
Prefetch('quotas',
|
Prefetch('quotas',
|
||||||
@@ -291,12 +275,19 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
|
|||||||
context['after'] = after
|
context['after'] = after
|
||||||
|
|
||||||
ebd = defaultdict(list)
|
ebd = defaultdict(list)
|
||||||
add_subevents_for_days(self.request.event.subevents.all(), before, after, ebd, set(), self.request.event,
|
add_subevents_for_days(
|
||||||
kwargs.get('cart_namespace'))
|
self.request.event.subevents_annotated(self.request.sales_channel),
|
||||||
|
before, after, ebd, set(), self.request.event,
|
||||||
|
kwargs.get('cart_namespace')
|
||||||
|
)
|
||||||
|
|
||||||
context['weeks'] = weeks_for_template(ebd, self.year, self.month)
|
context['weeks'] = weeks_for_template(ebd, self.year, self.month)
|
||||||
context['months'] = [date(self.year, i + 1, 1) for i in range(12)]
|
context['months'] = [date(self.year, i + 1, 1) for i in range(12)]
|
||||||
context['years'] = range(now().year - 2, now().year + 3)
|
context['years'] = range(now().year - 2, now().year + 3)
|
||||||
|
else:
|
||||||
|
context['subevent_list'] = self.request.event.subevents_sorted(
|
||||||
|
self.request.event.subevents_annotated(self.request.sales_channel)
|
||||||
|
)
|
||||||
|
|
||||||
context['show_cart'] = (
|
context['show_cart'] = (
|
||||||
context['cart']['positions'] and (
|
context['cart']['positions'] and (
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class OrganizerIndex(OrganizerViewMixin, ListView):
|
|||||||
).annotate(
|
).annotate(
|
||||||
order_from=Coalesce('min_from', 'date_from'),
|
order_from=Coalesce('min_from', 'date_from'),
|
||||||
).order_by('order_from')
|
).order_by('order_from')
|
||||||
qs = filter_qs_by_attr(qs, self.request)
|
qs = Event.annotated(filter_qs_by_attr(qs, self.request))
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -314,14 +314,14 @@ class CalendarView(OrganizerViewMixin, TemplateView):
|
|||||||
def _events_by_day(self, before, after):
|
def _events_by_day(self, before, after):
|
||||||
ebd = defaultdict(list)
|
ebd = defaultdict(list)
|
||||||
timezones = set()
|
timezones = set()
|
||||||
add_events_for_days(self.request, self.request.organizer.events, before, after, ebd, timezones)
|
add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web'), before, after, ebd, timezones)
|
||||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.objects.filter(
|
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||||
event__organizer=self.request.organizer,
|
event__organizer=self.request.organizer,
|
||||||
event__is_public=True,
|
event__is_public=True,
|
||||||
event__live=True,
|
event__live=True,
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'event___settings_objects', 'event__organizer___settings_objects'
|
'event___settings_objects', 'event__organizer___settings_objects'
|
||||||
), self.request), before, after, ebd, timezones)
|
)), self.request), before, after, ebd, timezones)
|
||||||
self._multiple_timezones = len(timezones) > 1
|
self._multiple_timezones = len(timezones) > 1
|
||||||
return ebd
|
return ebd
|
||||||
|
|
||||||
|
|||||||
@@ -1085,6 +1085,51 @@ class ItemTest(TestCase):
|
|||||||
i.active = False
|
i.active = False
|
||||||
assert not i.is_available()
|
assert not i.is_available()
|
||||||
|
|
||||||
|
def test_availability_filter(self):
|
||||||
|
i = Item.objects.create(
|
||||||
|
event=self.event, name="Ticket", default_price=23,
|
||||||
|
active=True, available_until=now() + timedelta(days=1),
|
||||||
|
)
|
||||||
|
assert Item.objects.filter_available().exists()
|
||||||
|
assert not Item.objects.filter_available(channel='foo').exists()
|
||||||
|
|
||||||
|
i.available_from = now() - timedelta(days=1)
|
||||||
|
i.save()
|
||||||
|
assert Item.objects.filter_available().exists()
|
||||||
|
i.available_from = now() + timedelta(days=1)
|
||||||
|
i.available_until = None
|
||||||
|
i.save()
|
||||||
|
assert not Item.objects.filter_available().exists()
|
||||||
|
i.available_from = None
|
||||||
|
i.available_until = now() - timedelta(days=1)
|
||||||
|
i.save()
|
||||||
|
assert not Item.objects.filter_available().exists()
|
||||||
|
i.available_from = None
|
||||||
|
i.available_until = None
|
||||||
|
i.save()
|
||||||
|
assert Item.objects.filter_available().exists()
|
||||||
|
i.active = False
|
||||||
|
i.save()
|
||||||
|
assert not Item.objects.filter_available().exists()
|
||||||
|
|
||||||
|
cat = ItemCategory.objects.create(
|
||||||
|
event=self.event, name='Foo', is_addon=True
|
||||||
|
)
|
||||||
|
i.active = True
|
||||||
|
i.category = cat
|
||||||
|
i.save()
|
||||||
|
assert not Item.objects.filter_available().exists()
|
||||||
|
assert Item.objects.filter_available(allow_addons=True).exists()
|
||||||
|
|
||||||
|
i.category = None
|
||||||
|
i.hide_without_voucher = True
|
||||||
|
i.save()
|
||||||
|
v = Voucher.objects.create(
|
||||||
|
event=self.event, item=i,
|
||||||
|
)
|
||||||
|
assert not Item.objects.filter_available().exists()
|
||||||
|
assert Item.objects.filter_available(voucher=v).exists()
|
||||||
|
|
||||||
|
|
||||||
class EventTest(TestCase):
|
class EventTest(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1207,6 +1252,100 @@ class EventTest(TestCase):
|
|||||||
assert event.presale_has_ended
|
assert event.presale_has_ended
|
||||||
assert not event.presale_is_running
|
assert not event.presale_is_running
|
||||||
|
|
||||||
|
def test_active_quotas_annotation(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
item2 = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=False)
|
||||||
|
q.items.add(item)
|
||||||
|
q.items.add(item2)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == [q]
|
||||||
|
assert Event.annotated(Event.objects, 'foo').first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_product_inactive(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=False)
|
||||||
|
q.items.add(item)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_product_addon(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
cat = ItemCategory.objects.create(
|
||||||
|
event=event, name='Foo', is_addon=True
|
||||||
|
)
|
||||||
|
item.category = cat
|
||||||
|
item.save()
|
||||||
|
q.items.add(item)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_product_unavailable(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True, available_until=now() - timedelta(days=1))
|
||||||
|
q.items.add(item)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_variation_not_in_quota(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
item.variations.create(value="foo")
|
||||||
|
q.items.add(item)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_variation(self):
|
||||||
|
event = Event.objects.create(
|
||||||
|
organizer=self.organizer, name='Download', slug='download',
|
||||||
|
date_from=now()
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=event, name='Quota', size=2)
|
||||||
|
item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
v = item.variations.create(value="foo")
|
||||||
|
item.variations.create(value="bar")
|
||||||
|
q.items.add(item)
|
||||||
|
q.variations.add(v)
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == [q]
|
||||||
|
item.available_until = now() - timedelta(days=1)
|
||||||
|
item.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
item.available_until = None
|
||||||
|
item.available_from = now() + timedelta(days=1)
|
||||||
|
item.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
item.available_until = None
|
||||||
|
item.available_from = None
|
||||||
|
item.active = False
|
||||||
|
item.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
item.active = True
|
||||||
|
item.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == [q]
|
||||||
|
assert Event.annotated(Event.objects, 'foo').first().active_quotas == []
|
||||||
|
v.active = False
|
||||||
|
v.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
item.hide_without_voucher = True
|
||||||
|
item.save()
|
||||||
|
assert Event.annotated(Event.objects).first().active_quotas == []
|
||||||
|
|
||||||
|
|
||||||
class SubEventTest(TestCase):
|
class SubEventTest(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1242,6 +1381,45 @@ class SubEventTest(TestCase):
|
|||||||
v.pk: Decimal('30.00')
|
v.pk: Decimal('30.00')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_active_quotas_annotation(self):
|
||||||
|
q = Quota.objects.create(event=self.event, name='Quota', size=2,
|
||||||
|
subevent=self.se)
|
||||||
|
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
q.items.add(item)
|
||||||
|
assert SubEvent.annotated(SubEvent.objects).first().active_quotas == [q]
|
||||||
|
assert SubEvent.annotated(SubEvent.objects, 'foo').first().active_quotas == []
|
||||||
|
|
||||||
|
def test_active_quotas_annotation_no_interference(self):
|
||||||
|
se2 = SubEvent.objects.create(
|
||||||
|
name='Testsub', date_from=now(), event=self.event
|
||||||
|
)
|
||||||
|
q = Quota.objects.create(event=self.event, name='Quota', size=2,
|
||||||
|
subevent=se2)
|
||||||
|
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
q.items.add(item)
|
||||||
|
assert SubEvent.annotated(SubEvent.objects).filter(pk=self.se.pk).first().active_quotas == []
|
||||||
|
assert SubEvent.annotated(SubEvent.objects).filter(pk=se2.pk).first().active_quotas == [q]
|
||||||
|
|
||||||
|
def test_best_availability(self):
|
||||||
|
q = Quota.objects.create(event=self.event, name='Quota', size=0,
|
||||||
|
subevent=self.se)
|
||||||
|
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True)
|
||||||
|
q.items.add(item)
|
||||||
|
obj = SubEvent.annotated(SubEvent.objects).first()
|
||||||
|
assert len(obj.active_quotas) == 1
|
||||||
|
assert obj.best_availability_state == Quota.AVAILABILITY_GONE
|
||||||
|
q2 = Quota.objects.create(event=self.event, name='Quota 2', size=1,
|
||||||
|
subevent=self.se)
|
||||||
|
q2.items.add(item)
|
||||||
|
obj = SubEvent.annotated(SubEvent.objects).first()
|
||||||
|
assert len(obj.active_quotas) == 2
|
||||||
|
assert obj.best_availability_state == Quota.AVAILABILITY_GONE
|
||||||
|
item2 = Item.objects.create(event=self.event, name='Regular ticket', default_price=10, active=True)
|
||||||
|
q2.items.add(item2)
|
||||||
|
obj = SubEvent.annotated(SubEvent.objects).first()
|
||||||
|
assert len(obj.active_quotas) == 2
|
||||||
|
assert obj.best_availability_state == Quota.AVAILABILITY_OK
|
||||||
|
|
||||||
|
|
||||||
class CachedFileTestCase(TestCase):
|
class CachedFileTestCase(TestCase):
|
||||||
def test_file_handling(self):
|
def test_file_handling(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user