Add public filters based on meta data (#3673)

* Add public filters based on meta data

* Fix licenseheaders

* ignore empty values

* Fix tests

* Full non-widget implementation

* Widget support

* Add a few tests

* Allow to reorder properties

* Fix isort

* Allow to opt-out for specific events

* Fix name clash between new and old field to make migration feasible
This commit is contained in:
Raphael Michel
2023-11-10 12:10:01 +01:00
committed by GitHub
parent c0007a9566
commit d7aa94d6ae
34 changed files with 829 additions and 82 deletions

View File

@@ -196,6 +196,10 @@ settings. For example, if you set up a meta data property called "Promoted" that
<pretix-widget event="https://pretix.eu/demo/series/" list-type="list" filter="attr[Promoted]=Yes"></pretix-widget> <pretix-widget event="https://pretix.eu/demo/series/" list-type="list" filter="attr[Promoted]=Yes"></pretix-widget>
If you have enabled public filters in your meta data attribute configuration, a filter formshows up. To disable, use::
<pretix-widget event="https://pretix.eu/demo/democon/" disable-filters></pretix-widget>
pretix Button pretix Button
------------- -------------

View File

@@ -230,8 +230,8 @@ class EventSerializer(I18nAwareModelSerializer):
for key, v in value['meta_data'].items(): for key, v in value['meta_data'].items():
if key not in self.meta_properties: if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
if self.meta_properties[key].allowed_values: if self.meta_properties[key].choices:
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: if v not in self.meta_properties[key].choice_keys:
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v)) raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
return value return value
@@ -528,8 +528,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
for key, v in value['meta_data'].items(): for key, v in value['meta_data'].items():
if key not in self.meta_properties: if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
if self.meta_properties[key].allowed_values: if self.meta_properties[key].choices:
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: if v not in self.meta_properties[key].choice_keys:
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v)) raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
return value return value
@@ -705,6 +705,7 @@ class EventSettingsSerializer(SettingsSerializer):
'frontpage_subevent_ordering', 'frontpage_subevent_ordering',
'event_list_type', 'event_list_type',
'event_list_available_only', 'event_list_available_only',
'event_list_filters',
'event_calendar_future_only', 'event_calendar_future_only',
'frontpage_text', 'frontpage_text',
'event_info_text', 'event_info_text',

View File

@@ -0,0 +1,52 @@
# Generated by Django 4.2.4 on 2023-10-31 10:08
import i18nfield.fields
from django.db import migrations, models
import pretix.helpers.json
def convert_allowed_values(apps, schema_editor):
EventMetaProperty = apps.get_model('pretixbase', 'EventMetaProperty')
for emp in EventMetaProperty.objects.filter(allowed_values__isnull=False):
emp.choices = [
{"key": _v.strip(), "label": {"en": _v.strip()}}
for _v in emp.allowed_values.splitlines()
]
emp.save(update_fields=["choices"])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0249_hidden_if_item_available"),
]
operations = [
migrations.AddField(
model_name="eventmetaproperty",
name="filter_public",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="eventmetaproperty",
name="public_label",
field=i18nfield.fields.I18nCharField(null=True),
),
migrations.AddField(
model_name="eventmetaproperty",
name="position",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="eventmetaproperty",
name="choices",
field=models.JSONField(null=True, encoder=pretix.helpers.json.CustomJSONEncoder),
),
migrations.RunPython(
convert_allowed_values,
migrations.RunPython.noop
),
migrations.RemoveField(
model_name="eventmetaproperty",
name="allowed_values",
),
]

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>. # the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
# #
@@ -33,6 +32,7 @@ import logging
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import logging
import os import os
import string import string
import uuid import uuid
@@ -71,7 +71,7 @@ from pretix.base.validators import EventSlugBanlistValidator
from pretix.helpers.database import GroupConcat from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange from pretix.helpers.daterange import daterange
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.helpers.json import safe_string from pretix.helpers.json import CustomJSONEncoder, safe_string
from pretix.helpers.thumb import get_thumbnail from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey from ..settings import settings_hierarkey
@@ -1647,26 +1647,40 @@ class EventMetaProperty(LoggedModel):
help_text=_("If checked, an event can only be taken live if the property is set. In event series, its always " help_text=_("If checked, an event can only be taken live if the property is set. In event series, its always "
"optional to set a value for individual dates") "optional to set a value for individual dates")
) )
allowed_values = models.TextField( choices = models.JSONField(
null=True, blank=True, null=True, blank=True,
encoder=CustomJSONEncoder,
verbose_name=_("Valid values"), verbose_name=_("Valid values"),
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.") )
filter_public = models.BooleanField(
default=False, verbose_name=_("Show filter option to customers"),
help_text=_("This field will be shown to filter events in the public event list and calendar.")
)
public_label = I18nCharField(
verbose_name=_("Public name"),
null=True, blank=True,
) )
filter_allowed = models.BooleanField( filter_allowed = models.BooleanField(
default=True, verbose_name=_("Can be used for filtering"), default=True, verbose_name=_("Can be used for filtering"),
help_text=_("This field will be shown to filter events or reports in the backend, and it can also be used " help_text=_("This field will be shown to filter events or reports in the backend, and it can also be used "
"for hidden filter parameters in the frontend (e.g. using the widget).") "for hidden filter parameters in the frontend (e.g. using the widget).")
) )
position = models.IntegerField(
default=0
)
def full_clean(self, exclude=None, validate_unique=True): def full_clean(self, exclude=None, validate_unique=True):
super().full_clean(exclude, validate_unique) super().full_clean(exclude, validate_unique)
if self.default and self.required: if self.default and self.required:
raise ValidationError(_("A property can either be required or have a default value, not both.")) raise ValidationError(_("A property can either be required or have a default value, not both."))
if self.default and self.allowed_values and self.default not in self.allowed_values.splitlines():
raise ValidationError(_("You cannot set a default value that is not a valid value."))
class Meta: class Meta:
ordering = ("name",) ordering = ("position", "name",)
@property
def choice_keys(self):
if self.choices:
return [v["key"] for v in self.choices]
class EventMetaValue(LoggedModel): class EventMetaValue(LoggedModel):

View File

@@ -1606,6 +1606,16 @@ DEFAULTS = {
help_text=_('If your event series has more than 50 dates in the future, only the month or week calendar can be used.') help_text=_('If your event series has more than 50 dates in the future, only the month or week calendar can be used.')
), ),
}, },
'event_list_filters': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Show filter options for calendar or list view"),
help_text=_("You can set up possible filters as meta properties in your organizer settings.")
)
},
'event_list_available_only': { 'event_list_available_only': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,

View File

@@ -316,12 +316,12 @@ class EventMetaValueForm(forms.ModelForm):
self.property = kwargs.pop('property') self.property = kwargs.pop('property')
self.disabled = kwargs.pop('disabled') self.disabled = kwargs.pop('disabled')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.property.allowed_values: if self.property.choices:
self.fields['value'] = forms.ChoiceField( self.fields['value'] = forms.ChoiceField(
label=self.property.name, label=self.property.name,
choices=[ choices=[
('', _('Default ({value})').format(value=self.property.default) if self.property.default else ''), ('', _('Default ({value})').format(value=self.property.default) if self.property.default else ''),
] + [(a.strip(), a.strip()) for a in self.property.allowed_values.splitlines()], ] + [(a.strip(), a.strip()) for a in self.property.choice_keys],
) )
else: else:
self.fields['value'].label = self.property.name self.fields['value'].label = self.property.name
@@ -558,6 +558,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
'low_availability_percentage', 'low_availability_percentage',
'event_list_type', 'event_list_type',
'event_list_available_only', 'event_list_available_only',
'event_list_filters',
'event_calendar_future_only', 'event_calendar_future_only',
'frontpage_text', 'frontpage_text',
'event_info_text', 'event_info_text',
@@ -645,6 +646,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
del self.fields['frontpage_subevent_ordering'] del self.fields['frontpage_subevent_ordering']
del self.fields['event_list_type'] del self.fields['event_list_type']
del self.fields['event_list_available_only'] del self.fields['event_list_available_only']
del self.fields['event_list_filters']
del self.fields['event_calendar_future_only'] del self.fields['event_calendar_future_only']
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields # create "virtual" fields for better UX when editing <name>_asked and <name>_required fields

View File

@@ -39,7 +39,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.forms import inlineformset_factory from django.forms import formset_factory, inlineformset_factory
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@@ -48,7 +48,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import ( from i18nfield.forms import (
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
) )
from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones from pytz import common_timezones
@@ -195,14 +195,50 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class EventMetaPropertyForm(forms.ModelForm): class EventMetaPropertyForm(I18nModelForm):
class Meta: class Meta:
model = EventMetaProperty model = EventMetaProperty
fields = ['name', 'default', 'required', 'protected', 'allowed_values', 'filter_allowed'] fields = ['name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed']
widgets = { widgets = {
'default': forms.TextInput() 'default': forms.TextInput(),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['public_label'].widget.attrs['data-display-dependency'] = '#id_filter_public'
class EventMetaPropertyAllowedValueForm(I18nForm):
key = forms.CharField(
label=_('Internal name'),
max_length=250,
required=True
)
label = I18nFormField(
label=_('Public name'),
required=False,
widget=I18nTextInput,
widget_kwargs=dict(attrs={
'placeholder': _('Public name'),
})
)
class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
# compatibility shim for django-i18nfield library
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer', None)
if self.organizer:
kwargs['locales'] = self.organizer.settings.get('locales')
super().__init__(*args, **kwargs)
EventMetaPropertyAllowedValueFormSet = formset_factory(
EventMetaPropertyAllowedValueForm, formset=I18nBaseFormSet,
can_order=True, can_delete=True, extra=0
)
class MembershipTypeForm(I18nModelForm): class MembershipTypeForm(I18nModelForm):
class Meta: class Meta:

View File

@@ -393,12 +393,12 @@ class SubEventMetaValueForm(forms.ModelForm):
self.default = kwargs.pop('default', None) self.default = kwargs.pop('default', None)
self.disabled = kwargs.pop('disabled', False) self.disabled = kwargs.pop('disabled', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.property.allowed_values: if self.property.choices:
self.fields['value'] = forms.ChoiceField( self.fields['value'] = forms.ChoiceField(
label=self.property.name, label=self.property.name,
choices=[ choices=[
('', _('Default ({value})').format(value=self.default or self.property.default) if self.default or self.property.default else ''), ('', _('Default ({value})').format(value=self.default or self.property.default) if self.default or self.property.default else ''),
] + [(a.strip(), a.strip()) for a in self.property.allowed_values.splitlines()], ] + [(a.strip(), a.strip()) for a in self.property.choice_keys],
) )
else: else:
self.fields['value'].label = self.property.name self.fields['value'].label = self.property.name

View File

@@ -316,6 +316,9 @@
{% if sform.event_list_available_only %} {% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %} {% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %} {% endif %}
{% if sform.event_list_filters %}
{% bootstrap_field sform.event_list_filters layout="control" %}
{% endif %}
{% if sform.event_calendar_future_only %} {% if sform.event_calendar_future_only %}
{% bootstrap_field sform.event_calendar_future_only layout="control" %} {% bootstrap_field sform.event_calendar_future_only layout="control" %}
{% endif %} {% endif %}

View File

@@ -14,29 +14,56 @@
<span class="fa fa-plus"></span> <span class="fa fa-plus"></span>
{% trans "Create a new property" %} {% trans "Create a new property" %}
</a> </a>
<table class="table table-condensed table-hover"> <form method="post">
<thead> {% csrf_token %}
<tr> <table class="table table-condensed table-hover">
<th>{% trans "Property" %}</th> <thead>
<th></th>
</tr>
</thead>
<tbody>
{% for p in properties %}
<tr> <tr>
<td><strong> <th>{% trans "Property" %}</th>
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"> <th class="iconcol"></th>
{{ p.name }} <th class="iconcol"></th>
</a> <th class="iconcol"></th>
</strong></td> <th class="action-col-2"></th>
<td class="text-right flip"> <th class="action-col-2"></th>
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.property.delete" organizer=request.organizer.slug property=p.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody data-dnd-url="{% url "control:organizer.properties.reorder" organizer=request.organizer.slug %}">
</table> {% for p in properties %}
<tr data-dnd-id="{{ p.pk }}">
<td><strong>
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}">
{{ p.name }}
</a>
</strong></td>
<td>
{% if p.filter_allowed %}
<span class="fa fa-filter text-muted" data-toggle="tooltip" title="{% trans "Can be used for filtering" %}"></span>
{% endif %}
</td>
<td>
{% if p.filter_public %}
<span class="fa fa-eye text-muted" data-toggle="tooltip" title="{% trans "Show filter option to customers" %}"></span>
{% endif %}
</td>
<td>
{% if p.protected %}
<span class="fa fa-lock text-muted" data-toggle="tooltip" title="{% trans "Can only be changed by organizer-level administrators" %}"></span>
{% endif %}
</td>
<td>
<button formaction="{% url "control:organizer.property.up" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:organizer.property.down" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.property.delete" organizer=request.organizer.slug property=p.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/organizers/base.html" %} {% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %} {% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inner %} {% block inner %}
{% if property %} {% if property %}
@@ -10,6 +11,78 @@
<form class="form-horizontal" action="" method="post"> <form class="form-horizontal" action="" method="post">
{% csrf_token %} {% csrf_token %}
{% bootstrap_form form layout="control" %} {% bootstrap_form form layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Allowed values" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-9">
<p class="help-block">{% trans "If you keep this empty, all input will be allowed." %}</p>
<div class="formset tax-rules-formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row tax-rule-line" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field formset.empty_form.key layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field formset.empty_form.label layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<div data-formset-body class="tax-rule-lines">
{% for form in formset %}
{% bootstrap_form_errors form %}
<div class="row tax-rule-line" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field form.key layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-4 col-lg-5">
{% bootstrap_field form.label layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<div class="row tax-rule-line" data-formset-form>
<div class="col-sm-12">
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new value" %}</button>
</div>
</div>
</div>
</div>
</div>
<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" %}

View File

@@ -133,6 +133,11 @@
{{ s.name }}</a></strong><br> {{ s.name }}</a></strong><br>
<small class="text-muted"> <small class="text-muted">
#{{ s.pk }} #{{ s.pk }}
{% for k, v in s.meta_data.items %}
{% if v %}
<small class="text-muted">&middot; {{ k }}: {{ v }}</small>
{% endif %}
{% endfor %}
</small> </small>
</td> </td>
<td> <td>

View File

@@ -126,6 +126,12 @@ urlpatterns = [
name='organizer.property.edit'), name='organizer.property.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(),
name='organizer.property.delete'), name='organizer.property.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/up$', organizer.meta_property_move_up,
name='organizer.property.up'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/down$', organizer.meta_property_move_down,
name='organizer.property.down'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/reorder$', organizer.reorder_meta_properties,
name='organizer.properties.reorder'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'), re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(),
name='organizer.membershiptype.add'), name='organizer.membershiptype.add'),

View File

@@ -37,6 +37,7 @@ import re
from datetime import time, timedelta from datetime import time, timedelta
from decimal import Decimal from decimal import Decimal
from hashlib import sha1 from hashlib import sha1
from json import JSONDecodeError
import bleach import bleach
import dateutil import dateutil
@@ -52,7 +53,9 @@ from django.db.models import (
) )
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce, Greatest
from django.forms import DecimalField from django.forms import DecimalField
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
@@ -60,6 +63,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from django.views import View from django.views import View
from django.views.decorators.http import require_http_methods
from django.views.generic import ( from django.views.generic import (
CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, CreateView, DetailView, FormView, ListView, TemplateView, UpdateView,
) )
@@ -97,17 +101,19 @@ from pretix.control.forms.filter import (
from pretix.control.forms.orders import ExporterForm from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import ( from pretix.control.forms.organizer import (
CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm, CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
EventMetaPropertyForm, GateForm, GiftCardAcceptanceInviteForm, EventMetaPropertyAllowedValueFormSet, EventMetaPropertyForm, GateForm,
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm,
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerFooterLinkFormset, OrganizerForm, OrganizerSettingsForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerUpdateForm, ReusableMediumCreateForm, ReusableMediumUpdateForm, OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
SSOClientForm, SSOProviderForm, TeamForm, WebHookForm, ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
WebHookForm,
) )
from pretix.control.forms.rrule import RRuleForm from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import ( from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
organizer_permission_required,
) )
from pretix.control.signals import nav_organizer from pretix.control.signals import nav_organizer
from pretix.control.views import PaginationMixin from pretix.control.views import PaginationMixin
@@ -2043,14 +2049,54 @@ class EventMetaPropertyListView(OrganizerDetailViewMixin, OrganizerPermissionReq
return self.request.organizer.meta_properties.all() return self.request.organizer.meta_properties.all()
class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): class EventMetaPropertyEditorMixin:
model = EventMetaProperty
template_name = 'pretixcontrol/organizers/property_edit.html' template_name = 'pretixcontrol/organizers/property_edit.html'
permission = 'can_change_organizer_settings'
form_class = EventMetaPropertyForm form_class = EventMetaPropertyForm
@cached_property
def formset(self):
return EventMetaPropertyAllowedValueFormSet(
data=self.request.POST if self.request.method == "POST" else None,
organizer=self.request.organizer,
initial=(self.object.choices or []) if self.object else [],
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
return ctx
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
'event': self.request.organizer,
}
def is_default_valid(self):
choice_keys = [
f.cleaned_data.get("key") for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
default = self.form.cleaned_data["default"]
if default and choice_keys and default not in choice_keys:
messages.error(self.request, _("You cannot set a default value that is not a valid value."))
return False
return True
def post(self, request, *args, **kwargs):
self.object = self.get_object(self.get_queryset())
self.form = self.get_form()
if self.form.is_valid() and self.formset.is_valid() and self.is_default_valid():
return self.form_valid(self.form)
else:
return self.form_invalid(self.form)
class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, EventMetaPropertyEditorMixin, CreateView):
model = EventMetaProperty
permission = 'can_change_organizer_settings'
def get_object(self, queryset=None): def get_object(self, queryset=None):
return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property')) return EventMetaProperty()
def get_success_url(self): def get_success_url(self):
return reverse('control:organizer.properties', kwargs={ return reverse('control:organizer.properties', kwargs={
@@ -2060,6 +2106,9 @@ class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionR
def form_valid(self, form): def form_valid(self, form):
messages.success(self.request, _('The property has been created.')) messages.success(self.request, _('The property has been created.'))
form.instance.organizer = self.request.organizer form.instance.organizer = self.request.organizer
form.instance.choices = [
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
ret = super().form_valid(form) ret = super().form_valid(form)
form.instance.log_action('pretix.property.created', user=self.request.user, data={ form.instance.log_action('pretix.property.created', user=self.request.user, data={
k: getattr(self.object, k) for k in form.changed_data k: getattr(self.object, k) for k in form.changed_data
@@ -2071,12 +2120,10 @@ class EventMetaPropertyCreateView(OrganizerDetailViewMixin, OrganizerPermissionR
return super().form_invalid(form) return super().form_invalid(form)
class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, EventMetaPropertyEditorMixin, UpdateView):
model = EventMetaProperty model = EventMetaProperty
template_name = 'pretixcontrol/organizers/property_edit.html'
permission = 'can_change_organizer_settings' permission = 'can_change_organizer_settings'
context_object_name = 'property' context_object_name = 'property'
form_class = EventMetaPropertyForm
def get_object(self, queryset=None): def get_object(self, queryset=None):
return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property')) return get_object_or_404(EventMetaProperty, organizer=self.request.organizer, pk=self.kwargs.get('property'))
@@ -2087,7 +2134,10 @@ class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionR
}) })
def form_valid(self, form): def form_valid(self, form):
if form.has_changed(): form.instance.choices = [
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
if form.has_changed() or self.formset.has_changed():
self.object.log_action('pretix.property.changed', user=self.request.user, data={ self.object.log_action('pretix.property.changed', user=self.request.user, data={
k: getattr(self.object, k) k: getattr(self.object, k)
for k in form.changed_data for k in form.changed_data
@@ -2124,6 +2174,75 @@ class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionR
return redirect(success_url) return redirect(success_url)
def meta_property_move(request, property, up=True):
property = get_object_or_404(request.organizer.meta_properties, id=property)
properties = list(request.organizer.meta_properties.order_by("position"))
index = properties.index(property)
if index != 0 and up:
properties[index - 1], properties[index] = properties[index], properties[index - 1]
elif index != len(properties) - 1 and not up:
properties[index + 1], properties[index] = properties[index], properties[index + 1]
for i, prop in enumerate(properties):
if prop.position != i:
prop.position = i
prop.save()
prop.log_action(
'pretix.property.reordered', user=request.user, data={
'position': i,
}
)
messages.success(request, _('The order of properties has been updated.'))
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def meta_property_move_up(request, organizer, property):
meta_property_move(request, property, up=True)
return redirect('control:organizer.properties',
organizer=request.organizer.slug)
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def meta_property_move_down(request, organizer, property):
meta_property_move(request, property, up=False)
return redirect('control:organizer.properties',
organizer=request.organizer.slug)
@transaction.atomic
@organizer_permission_required("can_change_items")
@require_http_methods(["POST"])
def reorder_meta_properties(request, organizer):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_meta_properties = list(request.organizer.meta_properties.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_meta_properties) != len(ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_meta_properties) != request.organizer.meta_properties.count():
raise Http404(_("Not all objects have been selected."))
for c in input_meta_properties:
pos = ids.index(str(c.pk))
if pos != c.position: # Save unneccessary UPDATE queries
c.position = pos
c.save(update_fields=['position'])
c.log_action(
'pretix.property.reordered', user=request.user, data={
'position': pos,
}
)
return HttpResponse()
class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView): class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/organizers/logs.html' template_name = 'pretixcontrol/organizers/logs.html'
permission = 'can_change_organizer_settings' permission = 'can_change_organizer_settings'

View File

@@ -120,7 +120,10 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
permission = 'can_change_settings' permission = 'can_change_settings'
def get_queryset(self): def get_queryset(self):
return super().get_queryset(True) return super().get_queryset(True).prefetch_related(
'meta_values',
'meta_values__property',
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)

View File

@@ -0,0 +1,90 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from django.conf import settings
from i18nfield.strings import LazyI18nString
from pretix.base.models import EventMetaValue, SubEventMetaValue
def meta_filtersets(organizer, event=None):
fields = {}
if not (event or organizer).settings.event_list_filters:
return fields
for prop in organizer.meta_properties.filter(filter_public=True):
if prop.choices:
choices = [(v["key"], str(LazyI18nString(v["label"])) or v["key"]) for v in prop.choices]
elif event:
existing_values = set()
if event.meta_data.get(prop.name):
existing_values.add(event.meta_data.get(prop.name))
existing_values |= set(SubEventMetaValue.objects.using(settings.DATABASE_REPLICA).filter(
property=prop,
subevent__event=event,
subevent__event__live=True,
subevent__event__is_public=True,
subevent__active=True,
subevent__is_public=True,
).values_list("value", flat=True).distinct())
choices = [(k, k) for k in sorted(existing_values)]
else:
existing_values = set()
if prop.default:
existing_values.add(prop.default)
existing_values |= set(EventMetaValue.objects.using(settings.DATABASE_REPLICA).filter(
property=prop,
event__organizer=organizer,
event__live=True,
event__is_public=True,
).values_list("value", flat=True).distinct())
existing_values |= set(SubEventMetaValue.objects.using(settings.DATABASE_REPLICA).filter(
property=prop,
subevent__event__organizer=organizer,
subevent__event__live=True,
subevent__event__is_public=True,
subevent__active=True,
subevent__is_public=True,
).values_list("value", flat=True).distinct())
choices = [(k, k) for k in sorted(existing_values)]
choices.insert(0, ("", ""))
if len(choices) > 1:
fields[f"attr[{prop.name}]"] = {
"label": str(prop.public_label) or prop.name,
"choices": choices
}
return fields
class EventListFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs)
for k, v in meta_filtersets(self.organizer, self.event).items():
self.fields[k] = forms.ChoiceField(
label=v["label"],
choices=v["choices"],
required=False,
)

View File

@@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load eventurl %} {% load eventurl %}
{% load urlreplace %} {% load urlreplace %}
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
<nav aria-label="{% trans "calendar navigation" %}"> <nav aria-label="{% trans "calendar navigation" %}">
<ul class="row calendar-nav"> <ul class="row calendar-nav">
<li class="col-sm-4 col-xs-2 text-left flip"> <li class="col-sm-4 col-xs-2 text-left flip">

View File

@@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load eventurl %} {% load eventurl %}
{% load urlreplace %} {% load urlreplace %}
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
<nav aria-label="{% trans "calendar navigation" %}"> <nav aria-label="{% trans "calendar navigation" %}">
<ul class="row calendar-nav"> <ul class="row calendar-nav">
<li class="col-sm-4 col-xs-2 text-left flip"> <li class="col-sm-4 col-xs-2 text-left flip">

View File

@@ -1,5 +1,6 @@
{% load i18n %} {% load i18n %}
{% load eventurl %} {% load eventurl %}
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
<ul class="list-unstyled"> <ul class="list-unstyled">
{% for subev in subevent_list.subevent_list %} {% for subev in subevent_list.subevent_list %}
<li class="subevent-row"> <li class="subevent-row">

View File

@@ -70,7 +70,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if subevent and "date" not in request.GET %} {% if subevent and "date" not in request.GET and "filtered" not in request.GET %}
<p> <p>
{% if show_cart %} {% if show_cart %}
<button class="subevent-toggle btn btn-primary btn-block btn-lg" aria-expanded="false"> <button class="subevent-toggle btn btn-primary btn-block btn-lg" aria-expanded="false">

View File

@@ -0,0 +1,25 @@
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load getitem %}
{% if filter_form.fields %}
<form class="event-list-filter-form" method="get">
<input type="hidden" name="filtered" value="1">
{% for f, v in request.GET.items %}
{% if f not in filter_form.fields %}
<input type="hidden" name="{{ f }}" value="{{ v }}">
{% endif %}
{% endfor %}
<div class="event-list-filter-form-row">
{% for f in filter_form.fields %}
{% bootstrap_field filter_form|getitem:f %}
{% endfor %}
<button type="submit" class="btn btn-primary">
<span class="fa fa-filter" aria-hidden="true"></span>
{% trans "Filter" %}
</button>
</div>
</form>
{% endif %}

View File

@@ -53,6 +53,7 @@
</div> </div>
</div> </div>
</form> </form>
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
{% include "pretixpresale/fragment_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} {% include "pretixpresale/fragment_calendar.html" with show_avail=request.organizer.settings.event_list_availability %}
{% if multiple_timezones %} {% if multiple_timezones %}

View File

@@ -46,6 +46,7 @@
</div> </div>
</div> </div>
</form> </form>
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
{% include "pretixpresale/fragment_day_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} {% include "pretixpresale/fragment_day_calendar.html" with show_avail=request.organizer.settings.event_list_availability %}
<div class="col-sm-4 visible-xs text-center"> <div class="col-sm-4 visible-xs text-center">
{% if has_before %} {% if has_before %}

View File

@@ -58,6 +58,7 @@
</div> </div>
</div> </div>
</form> </form>
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
{% include "pretixpresale/fragment_week_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} {% include "pretixpresale/fragment_week_calendar.html" with show_avail=request.organizer.settings.event_list_availability %}
<div class="col-sm-12 visible-sm visible-xs text-center"> <div class="col-sm-12 visible-sm visible-xs text-center">
{% if has_before %} {% if has_before %}

View File

@@ -33,6 +33,7 @@
</div> </div>
</div> </div>
</div> </div>
{% include "pretixpresale/fragment_event_list_filter.html" with request=request %}
{% if events %} {% if events %}
<div class="event-list"> <div class="event-list">
<div class="row hidden-xs hidden-sm"> <div class="row hidden-xs hidden-sm">
@@ -124,7 +125,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-md-2 col-xs-6 text-right flip"> <div class="col-md-2 col-xs-6 text-right flip">
<a class="btn btn-primary btn-block" href="{{ url }}"> <a class="btn btn-primary btn-block" href="{{ url }}{% if e.has_subevents and e.match_by_subevents %}{{ filterquery }}{% endif %}">
{% if e.has_subevents %}<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Tickets" %} {% if e.has_subevents %}<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Tickets" %}
{% elif e.presale_is_running and e.best_availability_state == 100 %} {% elif e.presale_is_running and e.best_availability_state == 100 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Tickets" %} <span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Tickets" %}

View File

@@ -34,6 +34,7 @@
import calendar import calendar
import hashlib import hashlib
import math import math
import operator
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from functools import reduce from functools import reduce
@@ -46,11 +47,12 @@ from django.conf import settings
from django.core.cache import caches from django.core.cache import caches
from django.db.models import Exists, Max, Min, OuterRef, Prefetch, Q from django.db.models import Exists, Max, Min, OuterRef, Prefetch, Q
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce, Greatest
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse, QueryDict
from django.shortcuts import redirect from django.shortcuts import redirect
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.formats import date_format, get_format from django.utils.formats import date_format, get_format
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now from django.utils.timezone import get_current_timezone, now
from django.views import View from django.views import View
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
@@ -68,19 +70,25 @@ from pretix.helpers.formats.en.formats import (
) )
from pretix.helpers.thumb import get_thumbnail from pretix.helpers.thumb import get_thumbnail
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.organizer import EventListFilterForm
from pretix.presale.ical import get_public_ical from pretix.presale.ical import get_public_ical
from pretix.presale.views import OrganizerViewMixin from pretix.presale.views import OrganizerViewMixin
def filter_qs_by_attr(qs, request): def filter_qs_by_attr(qs, request, match_subevents_with_conditions: Q=None):
""" """
We'll allow to filter the event list using attributes defined in the event meta data We'll allow to filter the event list using attributes defined in the event meta data
models in the format ?attr[meta_name]=meta_value models in the format ?attr[meta_name]=meta_value
:param qs: The base queryset over events or subevents
:param request: The request
:param match_subevents_with_conditions: If not None, an Event will also match if it has at least one subevent
fulfilling the conditions given and matching the search
""" """
attrs = {} attrs = {}
for i, item in enumerate(request.GET.items()): for i, item in enumerate(request.GET.items()):
k, v = item k, v = item
if k.startswith("attr[") and k.endswith("]"): if k.startswith("attr[") and k.endswith("]") and v.strip():
attrs[k[5:-1]] = v attrs[k[5:-1]] = v
skey = 'filter_qs_by_attr_{}_{}'.format(request.organizer.pk, request.event.pk if hasattr(request, 'event') else '') skey = 'filter_qs_by_attr_{}_{}'.format(request.organizer.pk, request.event.pk if hasattr(request, 'event') else '')
@@ -91,10 +99,11 @@ def filter_qs_by_attr(qs, request):
props = { props = {
p.name: p for p in request.organizer.meta_properties.filter( p.name: p for p in request.organizer.meta_properties.filter(
Q(filter_allowed=True) | Q(filter_public=True),
name__in=attrs.keys(), name__in=attrs.keys(),
filter_allowed=True,
) )
} }
conditions = []
for i, item in enumerate(attrs.items()): for i, item in enumerate(attrs.items()):
attr, v = item attr, v = item
@@ -136,13 +145,42 @@ def filter_qs_by_attr(qs, request):
annotations['attr_{}_any'.format(i)] = Exists(emv_with_any_value) annotations['attr_{}_any'.format(i)] = Exists(emv_with_any_value)
filters |= Q(**{'attr_{}_any'.format(i): False}) filters |= Q(**{'attr_{}_any'.format(i): False})
qs = qs.annotate(**annotations).filter(filters) qs = qs.annotate(**annotations)
conditions.append(filters)
if conditions:
if match_subevents_with_conditions:
qs = qs.annotate(
match_by_subevents=Exists(
filter_qs_by_attr(
SubEvent.objects.filter(
match_subevents_with_conditions,
event=OuterRef('pk'),
),
request,
)
)
).filter(reduce(operator.and_, conditions) | Q(match_by_subevents=True))
else:
qs = qs.filter(reduce(operator.and_, conditions))
return qs return qs
class EventListMixin: class EventListMixin:
@cached_property
def filter_form(self):
return EventListFilterForm(
data=self.request.GET,
organizer=self.request.organizer,
event=getattr(self.request, 'event', None),
)
def _get_event_queryset(self): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
def _get_event_list_queryset(self):
query = Q(is_public=True) & Q(live=True) query = Q(is_public=True) & Q(live=True)
qs = self.request.organizer.events.using(settings.DATABASE_REPLICA).filter(query) qs = self.request.organizer.events.using(settings.DATABASE_REPLICA).filter(query)
qs = qs.filter(sales_channels__contains=self.request.sales_channel.identifier) qs = qs.filter(sales_channels__contains=self.request.sales_channel.identifier)
@@ -154,26 +192,26 @@ class EventListMixin:
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from')), max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from')),
) )
if "old" in self.request.GET: if "old" in self.request.GET:
date_q = Q(date_to__lt=now()) | (Q(date_to__isnull=True) & Q(date_from__lt=now()))
qs = qs.filter( qs = qs.filter(
Q(Q(has_subevents=False) & Q( Q(Q(has_subevents=False) & date_q) | Q(
Q(date_to__lt=now()) | Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) Q(has_subevents=True) & Q(Q(min_to__lt=now()) | Q(min_from__lt=now()))
)) | Q(Q(has_subevents=True) & Q(
Q(min_to__lt=now()) | Q(min_from__lt=now()))
) )
).annotate( ).annotate(
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'), order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'),
).order_by('-order_to') ).order_by('-order_to')
else: else:
date_q = Q(date_to__gte=now()) | (Q(date_to__isnull=True) & Q(date_from__gte=now()))
qs = qs.filter( qs = qs.filter(
Q(Q(has_subevents=False) & Q( Q(Q(has_subevents=False) & date_q) | Q(Q(has_subevents=True) & Q(
Q(date_to__gte=now()) | Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
)) | Q(Q(has_subevents=True) & Q(
Q(max_to__gte=now()) | Q(max_from__gte=now())) Q(max_to__gte=now()) | Q(max_from__gte=now()))
) )
).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 = Event.annotated(filter_qs_by_attr(qs, self.request)) qs = Event.annotated(filter_qs_by_attr(
qs, self.request, match_subevents_with_conditions=Q(active=True) & Q(is_public=True) & date_q
))
return qs return qs
def _set_month_to_next_subevent(self): def _set_month_to_next_subevent(self):
@@ -387,7 +425,7 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return self._get_event_queryset() return self._get_event_list_queryset()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
@@ -398,6 +436,14 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView):
event.min_from.astimezone(event.tzname), event.min_from.astimezone(event.tzname),
(event.max_fromto or event.max_to or event.max_from).astimezone(event.tzname) (event.max_fromto or event.max_to or event.max_from).astimezone(event.tzname)
) )
query_data = self.request.GET.copy()
filter_query_data = QueryDict(mutable=True)
for k, v in query_data.items():
if k.startswith("attr[") and v:
filter_query_data[k] = v
ctx["filterquery"] = f"?{filter_query_data.urlencode()}" if filter_query_data else ""
return ctx return ctx

View File

@@ -63,6 +63,7 @@ from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.daterange import daterange from pretix.helpers.daterange import daterange
from pretix.helpers.thumb import get_thumbnail from pretix.helpers.thumb import get_thumbnail
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.organizer import meta_filtersets
from pretix.presale.views.cart import get_or_create_cart_id from pretix.presale.views.cart import get_or_create_cart_id
from pretix.presale.views.event import ( from pretix.presale.views.event import (
get_grouped_items, item_group_by_category, get_grouped_items, item_group_by_category,
@@ -464,6 +465,9 @@ class WidgetAPIProductList(EventListMixin, View):
o = getattr(request, 'event', request.organizer) o = getattr(request, 'event', request.organizer)
list_type = self.request.GET.get("style", o.settings.event_list_type) list_type = self.request.GET.get("style", o.settings.event_list_type)
data['list_type'] = list_type data['list_type'] = list_type
data['meta_filter_fields'] = [
{**v, "key": k} for k, v in meta_filtersets(request.organizer, getattr(request, 'event', None)).items()
]
if hasattr(self.request, 'event') and data['list_type'] not in ("calendar", "week"): if hasattr(self.request, 'event') and data['list_type'] not in ("calendar", "week"):
# only allow list-view of more than 50 subevents if ordering is by data as this can be done in the database # only allow list-view of more than 50 subevents if ordering is by data as this can be done in the database
@@ -596,7 +600,14 @@ class WidgetAPIProductList(EventListMixin, View):
offset = int(self.request.GET.get("offset", 0)) offset = int(self.request.GET.get("offset", 0))
limit = 50 limit = 50
if hasattr(self.request, 'event'): if hasattr(self.request, 'event'):
evs = filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier), self.request) evs = filter_qs_by_attr(
self.request.event.subevents_annotated(self.request.sales_channel.identifier),
self.request,
match_subevents_with_conditions=(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
| Q(date_to__gte=now() - timedelta(hours=24))
),
)
evs = self.request.event.subevents_sorted(evs) evs = self.request.event.subevents_sorted(evs)
ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str) ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
data['has_more_events'] = False data['has_more_events'] = False
@@ -629,7 +640,7 @@ class WidgetAPIProductList(EventListMixin, View):
] ]
else: else:
data['events'] = [] data['events'] = []
qs = self._get_event_queryset() qs = self._get_event_list_queryset()
for event in qs: for event in qs:
tz = ZoneInfo(event.cache.get_or_set('timezone', lambda: event.settings.timezone)) tz = ZoneInfo(event.cache.get_or_set('timezone', lambda: event.settings.timezone))
if event.has_subevents: if event.has_subevents:

View File

@@ -1093,6 +1093,46 @@ Vue.component('pretix-widget-event-form', {
} }
}); });
Vue.component('pretix-widget-event-list-filter-field', {
template: ('<div class="pretix-widget-event-list-filter-field">'
+ '<label :for="id">{{ field.label }}</label>'
+ '<select :id="id" :name="field.key" @change="onChange($event)" :value="currentValue">'
+ '<option v-for="choice in field.choices" :value="choice[0]">{{ choice[1] }}</option>'
+ '</select>'
+ '</div>'),
props: {
field: Object
},
methods: {
onChange: function(event) {
var filterParams = new URLSearchParams(this.$root.filter);
if (event.target.value) {
filterParams.set(this.field.key, event.target.value);
} else {
filterParams.delete(this.field.key);
}
this.$root.filter = filterParams.toString();
this.$root.loading++;
this.$root.reload();
},
},
computed: {
id: function () {
return widget_id + "_" + this.field.key;
},
currentValue: function () {
var filterParams = new URLSearchParams(this.$root.filter);
return filterParams.get(this.field.key) || "";
},
},
});
Vue.component('pretix-widget-event-list-filter-form', {
template: ('<div class="pretix-widget-event-list-filter-form">'
+ '<pretix-widget-event-list-filter-field v-for="field in $root.meta_filter_fields" :field="field" :key="field.key"></pretix-widget-event-list-filter-field>'
+ '</div>'),
});
Vue.component('pretix-widget-event-list-entry', { Vue.component('pretix-widget-event-list-entry', {
template: ('<a :class="classObject" @click.prevent.stop="select">' template: ('<a :class="classObject" @click.prevent.stop="select">'
+ '<div class="pretix-widget-event-list-entry-name">{{ event.name }}</div>' + '<div class="pretix-widget-event-list-entry-name">{{ event.name }}</div>'
@@ -1142,6 +1182,7 @@ Vue.component('pretix-widget-event-list', {
+ '<strong>{{ $root.name }}</strong>' + '<strong>{{ $root.name }}</strong>'
+ '</div>' + '</div>'
+ '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>' + '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>'
+ '<pretix-widget-event-list-filter-form v-if="!$root.disable_filters && $root.meta_filter_fields.length > 0"></pretix-widget-event-list-filter-form>'
+ '<pretix-widget-event-list-entry v-for="event in $root.events" :event="event" :key="event.url"></pretix-widget-event-list-entry>' + '<pretix-widget-event-list-entry v-for="event in $root.events" :event="event" :key="event.url"></pretix-widget-event-list-entry>'
+ '<p class="pretix-widget-event-list-load-more" v-if="$root.has_more_events"><button @click.prevent.stop="load_more">'+strings.load_more+'</button></p>' + '<p class="pretix-widget-event-list-load-more" v-if="$root.has_more_events"><button @click.prevent.stop="load_more">'+strings.load_more+'</button></p>'
+ '</div>'), + '</div>'),
@@ -1360,6 +1401,9 @@ Vue.component('pretix-widget-event-calendar', {
+ '</div>' + '</div>'
+ '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>' + '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>'
// Filter
+ '<pretix-widget-event-list-filter-form v-if="!$root.disable_filters && $root.meta_filter_fields.length > 0"></pretix-widget-event-list-filter-form>'
// Calendar navigation // Calendar navigation
+ '<div class="pretix-widget-event-calendar-head">' + '<div class="pretix-widget-event-calendar-head">'
+ '<a class="pretix-widget-event-calendar-previous-month" href="#" @click.prevent.stop="prevmonth" role="button">&laquo; ' + '<a class="pretix-widget-event-calendar-previous-month" href="#" @click.prevent.stop="prevmonth" role="button">&laquo; '
@@ -1442,6 +1486,9 @@ Vue.component('pretix-widget-event-week-calendar', {
+ '<strong>{{ $root.name }}</strong>' + '<strong>{{ $root.name }}</strong>'
+ '</div>' + '</div>'
// Filter
+ '<pretix-widget-event-list-filter-form v-if="!$root.disable_filters && $root.meta_filter_fields.length > 0"></pretix-widget-event-list-filter-form>'
// Calendar navigation // Calendar navigation
+ '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>' + '<div class="pretix-widget-event-description" v-if="$root.parent_stack.length > 0 && $root.frontpage_text" v-html="$root.frontpage_text"></div>'
+ '<div class="pretix-widget-event-calendar-head">' + '<div class="pretix-widget-event-calendar-head">'
@@ -1671,6 +1718,7 @@ var shared_root_methods = {
root.view = "weeks"; root.view = "weeks";
root.name = data.name; root.name = data.name;
root.frontpage_text = data.frontpage_text; root.frontpage_text = data.frontpage_text;
root.meta_filter_fields = data.meta_filter_fields;
} else if (data.days !== undefined) { } else if (data.days !== undefined) {
root.days = data.days; root.days = data.days;
root.date = null; root.date = null;
@@ -1679,6 +1727,7 @@ var shared_root_methods = {
root.view = "days"; root.view = "days";
root.name = data.name; root.name = data.name;
root.frontpage_text = data.frontpage_text; root.frontpage_text = data.frontpage_text;
root.meta_filter_fields = data.meta_filter_fields;
} else if (data.events !== undefined) { } else if (data.events !== undefined) {
root.events = root.append_events && root.events ? root.events.concat(data.events) : data.events; root.events = root.append_events && root.events ? root.events.concat(data.events) : data.events;
root.append_events = false; root.append_events = false;
@@ -1687,6 +1736,7 @@ var shared_root_methods = {
root.name = data.name; root.name = data.name;
root.frontpage_text = data.frontpage_text; root.frontpage_text = data.frontpage_text;
root.has_more_events = data.has_more_events; root.has_more_events = data.has_more_events;
root.meta_filter_fields = data.meta_filter_fields;
} else { } else {
root.view = "event"; root.view = "event";
root.name = data.name; root.name = data.name;
@@ -1917,6 +1967,7 @@ var create_widget = function (element) {
var skip_ssl = element.attributes["skip-ssl-check"] ? true : false; var skip_ssl = element.attributes["skip-ssl-check"] ? true : false;
var disable_iframe = element.attributes["disable-iframe"] ? true : false; var disable_iframe = element.attributes["disable-iframe"] ? true : false;
var disable_vouchers = element.attributes["disable-vouchers"] ? true : false; var disable_vouchers = element.attributes["disable-vouchers"] ? true : false;
var disable_filters = element.attributes["disable-filters"] ? true : false;
var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)); var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data));
var filter = element.attributes.filter ? element.attributes.filter.value : null; var filter = element.attributes.filter ? element.attributes.filter.value : null;
var items = element.attributes.items ? element.attributes.items.value : null; var items = element.attributes.items ? element.attributes.items.value : null;
@@ -1990,12 +2041,14 @@ var create_widget = function (element) {
widget_id: 'pretix-widget-' + widget_id, widget_id: 'pretix-widget-' + widget_id,
vouchers_exist: false, vouchers_exist: false,
disable_vouchers: disable_vouchers, disable_vouchers: disable_vouchers,
disable_filters: disable_filters,
cart_exists: false, cart_exists: false,
itemcount: 0, itemcount: 0,
overlay: null, overlay: null,
poweredby: "", poweredby: "",
has_seating_plan: false, has_seating_plan: false,
has_seating_plan_waitinglist: false, has_seating_plan_waitinglist: false,
meta_filter_fields: [],
} }
}, },
created: function () { created: function () {

View File

@@ -139,6 +139,41 @@
} }
} }
.event-list-filter-form {
.event-list-filter-form-row {
display: flex;
flex-direction: row;
align-items: end;
.form-group {
display: block;
width: 100%;
margin: 0 15px 0 0;
}
button {
flex: 0;
white-space: nowrap;
/* Visual alignment with the selects */
padding-top: 7px;
padding-bottom: 7px;
}
}
margin-bottom: 15px;
}
@media (max-width: $screen-xs-max) {
.event-list-filter-form {
.event-list-filter-form-row {
display: flex;
flex-direction: column;
align-items: stretch;
.form-group {
display: block;
width: 100%;
margin: 0 0 5px;
}
}
}
}

View File

@@ -99,7 +99,7 @@
input:checked + .pretix-widget-icon-cart { input:checked + .pretix-widget-icon-cart {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.534 3.097a.317.317 0 0 1-.067.197L3.56 4.42a.207.207 0 0 1-.16.084.207.207 0 0 1-.159-.084l-.907-1.126a.317.317 0 0 1-.067-.197c0-.154.103-.282.227-.282.06 0 .117.031.159.084l.521.642V2.252c0-.154.102-.281.226-.281.124 0 .227.127.227.281v1.289l.521-.642a.205.205 0 0 1 .159-.084c.124 0 .227.128.227.282ZM2.267 6.756c0-.312-.202-.563-.453-.563-.252 0-.454.251-.454.563 0 .312.202.563.454.563.251 0 .453-.251.453-.563Zm3.174 0c0-.312-.202-.563-.454-.563-.251 0-.453.251-.453.563 0 .312.202.563.453.563.252 0 .454-.251.454-.563Zm.453-4.785c0-.154-.103-.282-.227-.282H1.413c-.035-.211-.039-.563-.28-.563H.227c-.124 0-.227.128-.227.282 0 .153.103.281.227.281h.722l.627 3.62c-.049.127-.216.466-.216.603 0 .153.103.281.227.281h3.627c.124 0 .227-.128.227-.281 0-.154-.103-.282-.227-.282H1.955c.036-.088.085-.18.085-.281 0-.102-.032-.212-.046-.308l3.698-.537c.117-.018.202-.141.202-.281V1.971Z' transform='matrix(2.52069 0 0 2.02994 -.035 -.523)'/%3E%3C/svg%3E%0A"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.534 3.097a.317.317 0 0 1-.067.197L3.56 4.42a.207.207 0 0 1-.16.084.207.207 0 0 1-.159-.084l-.907-1.126a.317.317 0 0 1-.067-.197c0-.154.103-.282.227-.282.06 0 .117.031.159.084l.521.642V2.252c0-.154.102-.281.226-.281.124 0 .227.127.227.281v1.289l.521-.642a.205.205 0 0 1 .159-.084c.124 0 .227.128.227.282ZM2.267 6.756c0-.312-.202-.563-.453-.563-.252 0-.454.251-.454.563 0 .312.202.563.454.563.251 0 .453-.251.453-.563Zm3.174 0c0-.312-.202-.563-.454-.563-.251 0-.453.251-.453.563 0 .312.202.563.453.563.252 0 .454-.251.454-.563Zm.453-4.785c0-.154-.103-.282-.227-.282H1.413c-.035-.211-.039-.563-.28-.563H.227c-.124 0-.227.128-.227.282 0 .153.103.281.227.281h.722l.627 3.62c-.049.127-.216.466-.216.603 0 .153.103.281.227.281h3.627c.124 0 .227-.128.227-.281 0-.154-.103-.282-.227-.282H1.955c.036-.088.085-.18.085-.281 0-.102-.032-.212-.046-.308l3.698-.537c.117-.018.202-.141.202-.281V1.971Z' transform='matrix(2.52069 0 0 2.02994 -.035 -.523)'/%3E%3C/svg%3E%0A");
} }
input[type="text"], input[type="number"] { input[type="text"], input[type="number"], select {
line-height: normal; line-height: normal;
border: 1px solid $input-border; border: 1px solid $input-border;
border-radius: $input-border-radius; border-radius: $input-border-radius;
@@ -640,6 +640,42 @@
} }
} }
.pretix-widget-event-list-filter-form {
display: flex;
flex-direction: row;
align-items: end;
margin-bottom: 15px;
.pretix-widget-event-list-filter-field {
display: block;
width: 100%;
margin: 0 15px 0 0;
label {
display: inline-block;
font-weight: bold;
margin-bottom: 5px;
}
select {
display: block;
width: 100%;
}
}
.pretix-widget-event-list-filter-field:last-child {
margin: 0;
}
}
.pretix-widget.pretix-widget-mobile .pretix-widget-event-list-filter-form {
display: block;
.pretix-widget-event-list-filter-field {
display: block;
margin: 0 0 5px;
}
}
@keyframes pretix-widget-bounce-in { @keyframes pretix-widget-bounce-in {
0% { 0% {
transform: scale(0); transform: scale(0);

View File

@@ -198,6 +198,10 @@ def test_event_list_filter(token_client, organizer, event):
resp = token_client.get('/api/v1/organizers/{}/events/?attr[type]='.format(organizer.slug)) resp = token_client.get('/api/v1/organizers/{}/events/?attr[type]='.format(organizer.slug))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.data['count'] == 1
resp = token_client.get('/api/v1/organizers/{}/events/?attr[type]=Unknown'.format(organizer.slug))
assert resp.status_code == 200
assert resp.data['count'] == 0 assert resp.data['count'] == 0
resp = token_client.get('/api/v1/organizers/{}/events/?date_from_after=2017-12-27T10:00:00Z'.format(organizer.slug)) resp = token_client.get('/api/v1/organizers/{}/events/?date_from_after=2017-12-27T10:00:00Z'.format(organizer.slug))
@@ -231,7 +235,10 @@ def test_event_get(token_client, organizer, event):
@pytest.mark.django_db @pytest.mark.django_db
def test_event_create(team, token_client, organizer, event, meta_prop): def test_event_create(team, token_client, organizer, event, meta_prop):
meta_prop.allowed_values = "Conference\nWorkshop" meta_prop.choices = [
{"key": "Conference", "label": {"en": "Conference"}},
{"key": "Workshop", "label": {"en": "Workshop"}},
]
meta_prop.save() meta_prop.save()
team.can_change_organizer_settings = False team.can_change_organizer_settings = False
team.save() team.save()

View File

@@ -256,7 +256,7 @@ def test_all_subevents_list_filter(token_client, organizer, event, subevent):
@pytest.mark.django_db @pytest.mark.django_db
def test_subevent_create(team, token_client, organizer, event, subevent, meta_prop, item): def test_subevent_create(team, token_client, organizer, event, subevent, meta_prop, item):
meta_prop.allowed_values = "Conference\nWorkshop" meta_prop.choices = [{"key": "Conference", "label": {"en": "Conference"}}, {"key": "Workshop", "label": {"en": "Workshop"}}]
meta_prop.save() meta_prop.save()
team.can_change_organizer_settings = False team.can_change_organizer_settings = False
team.save() team.save()

View File

@@ -72,6 +72,21 @@ def test_attributes_on_page(env, client):
r = client.get('/mrmcd/?attr[loc]=HH') r = client.get('/mrmcd/?attr[loc]=HH')
assert 'MRMCD2015' in r.rendered_content assert 'MRMCD2015' in r.rendered_content
with scopes_disabled():
series = env[0].events.create(
name="Workshop Series",
has_subevents=True,
live=True,
date_from=now() + timedelta(days=3)
)
se = series.subevents.create(name="Future", active=True, date_from=now() + timedelta(days=3))
se.meta_values.create(property=prop, value="B")
r = client.get('/mrmcd/?attr[loc]=B')
assert 'Workshop Series' in r.rendered_content
r = client.get('/mrmcd/?attr[loc]=MA')
assert 'Workshop Series' not in r.rendered_content
prop.filter_allowed = False prop.filter_allowed = False
prop.save() prop.save()
r = client.get('/mrmcd/?attr[loc]=MA') r = client.get('/mrmcd/?attr[loc]=MA')

View File

@@ -605,6 +605,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
data = json.loads(response.content.decode()) data = json.loads(response.content.decode())
assert data == { assert data == {
'list_type': 'list', 'list_type': 'list',
'meta_filter_fields': [],
'name': '30C3', 'name': '30C3',
'frontpage_text': '', 'frontpage_text': '',
'poweredby': '<a href="https://pretix.eu" target="_blank" rel="noopener">ticketing powered by pretix</a>', 'poweredby': '<a href="https://pretix.eu" target="_blank" rel="noopener">ticketing powered by pretix</a>',
@@ -633,6 +634,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
data = json.loads(response.content.decode()) data = json.loads(response.content.decode())
assert data == { assert data == {
'list_type': 'calendar', 'list_type': 'calendar',
'meta_filter_fields': [],
'date': '2019-01-01', 'date': '2019-01-01',
'name': '30C3', 'name': '30C3',
'frontpage_text': '', 'frontpage_text': '',
@@ -708,6 +710,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
data = json.loads(response.content.decode()) data = json.loads(response.content.decode())
assert data == { assert data == {
'list_type': 'week', 'list_type': 'week',
'meta_filter_fields': [],
'name': '30C3', 'name': '30C3',
'frontpage_text': '', 'frontpage_text': '',
'week': [2019, 1], 'week': [2019, 1],
@@ -769,9 +772,72 @@ class WidgetCartTest(CartTestMixin, TestCase):
'event_url': 'http://example.com/ccc/future/', 'event_url': 'http://example.com/ccc/future/',
'name': 'Future'} 'name': 'Future'}
], ],
'list_type': 'list' 'list_type': 'list',
'meta_filter_fields': [],
} }
def test_event_list_filtersets_from_allowed_values(self):
self.event.has_subevents = True
self.event.settings.timezone = 'Europe/Berlin'
self.event.save()
with freeze_time("2019-01-01 10:00:00"):
with scopes_disabled():
self.orga.meta_properties.create(
name="Language",
default="EN",
filter_public=True,
choices=[
{"key": "EN", "label": "English"},
{"key": "DE", "label": "German"},
]
)
response = self.client.get('/%s/widget/product_list' % (self.orga.slug,))
data = json.loads(response.content.decode())
assert data["meta_filter_fields"] == [
{
"choices": [["", ""], ["EN", "English"], ["DE", "German"]],
"key": "attr[Language]",
"label": "Language"
}
]
def test_event_list_filtersets_from_existing_values(self):
self.event.has_subevents = True
self.event.settings.timezone = 'Europe/Berlin'
self.event.save()
with freeze_time("2019-01-01 10:00:00"):
with scopes_disabled():
p = self.orga.meta_properties.create(
name="Language",
default="DE",
filter_public=True,
)
e = self.orga.events.create(name="Future", live=True, is_public=True, slug='future', date_from=now() + datetime.timedelta(days=3))
se = self.event.subevents.create(name="Future", active=True, date_from=now() + datetime.timedelta(days=3))
e.meta_values.create(property=p, value="EN")
se.meta_values.create(property=p, value="DE")
response = self.client.get('/%s/widget/product_list' % (self.orga.slug,))
data = json.loads(response.content.decode())
assert data["meta_filter_fields"] == [
{
"choices": [["", ""], ["DE", "DE"], ["EN", "EN"]],
"key": "attr[Language]",
"label": "Language"
}
]
response = self.client.get('/%s/%s/widget/product_list' % (self.orga.slug, self.event.slug))
data = json.loads(response.content.decode())
assert data["meta_filter_fields"] == [
{
"choices": [["", ""], ["DE", "DE"]],
"key": "attr[Language]",
"label": "Language"
}
]
def test_event_calendar(self): def test_event_calendar(self):
self.event.has_subevents = True self.event.has_subevents = True
self.event.settings.timezone = 'Europe/Berlin' self.event.settings.timezone = 'Europe/Berlin'
@@ -794,6 +860,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
assert data == { assert data == {
'date': '2019-01-01', 'date': '2019-01-01',
'list_type': 'calendar', 'list_type': 'calendar',
'meta_filter_fields': [],
'poweredby': '<a href="https://pretix.eu" target="_blank" rel="noopener">ticketing powered by pretix</a>', 'poweredby': '<a href="https://pretix.eu" target="_blank" rel="noopener">ticketing powered by pretix</a>',
'weeks': [ 'weeks': [
[None, [None,