From d7aa94d6ae69f6455f17f3d1fdf8eae4590e6f32 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 10 Nov 2023 12:10:01 +0100 Subject: [PATCH] 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 --- doc/user/events/widget.rst | 4 + src/pretix/api/serializers/event.py | 9 +- .../0250_eventmetaproperty_filter_public.py | 52 ++++++ src/pretix/base/models/event.py | 28 +++- src/pretix/base/settings.py | 10 ++ src/pretix/control/forms/event.py | 6 +- src/pretix/control/forms/organizer.py | 46 +++++- src/pretix/control/forms/subevents.py | 4 +- .../pretixcontrol/event/settings.html | 3 + .../pretixcontrol/organizers/properties.html | 73 ++++++--- .../organizers/property_edit.html | 73 +++++++++ .../pretixcontrol/subevents/index.html | 5 + src/pretix/control/urls.py | 6 + src/pretix/control/views/organizer.py | 149 ++++++++++++++++-- src/pretix/control/views/subevents.py | 5 +- src/pretix/presale/forms/organizer.py | 90 +++++++++++ .../event/fragment_subevent_calendar.html | 1 + .../fragment_subevent_calendar_week.html | 1 + .../event/fragment_subevent_list.html | 1 + .../templates/pretixpresale/event/index.html | 2 +- .../fragment_event_list_filter.html | 25 +++ .../pretixpresale/organizers/calendar.html | 1 + .../organizers/calendar_day.html | 1 + .../organizers/calendar_week.html | 1 + .../pretixpresale/organizers/index.html | 3 +- src/pretix/presale/views/organizer.py | 76 +++++++-- src/pretix/presale/views/widget.py | 15 +- .../static/pretixpresale/js/widget/widget.js | 53 +++++++ .../static/pretixpresale/scss/_calendar.scss | 35 ++++ .../static/pretixpresale/scss/widget.scss | 38 ++++- src/tests/api/test_events.py | 9 +- src/tests/api/test_subevents.py | 2 +- src/tests/presale/test_organizer_page.py | 15 ++ src/tests/presale/test_widget.py | 69 +++++++- 34 files changed, 829 insertions(+), 82 deletions(-) create mode 100644 src/pretix/base/migrations/0250_eventmetaproperty_filter_public.py create mode 100644 src/pretix/presale/forms/organizer.py create mode 100644 src/pretix/presale/templates/pretixpresale/fragment_event_list_filter.html diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst index f19f3cd285..64a60e4952 100644 --- a/doc/user/events/widget.rst +++ b/doc/user/events/widget.rst @@ -196,6 +196,10 @@ settings. For example, if you set up a meta data property called "Promoted" that +If you have enabled public filters in your meta data attribute configuration, a filter formshows up. To disable, use:: + + + pretix Button ------------- diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 6bfed06ad1..5ac98d62b9 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -230,8 +230,8 @@ class EventSerializer(I18nAwareModelSerializer): for key, v in value['meta_data'].items(): if key not in self.meta_properties: raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) - if self.meta_properties[key].allowed_values: - if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: + if self.meta_properties[key].choices: + 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)) return value @@ -528,8 +528,8 @@ class SubEventSerializer(I18nAwareModelSerializer): for key, v in value['meta_data'].items(): if key not in self.meta_properties: raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) - if self.meta_properties[key].allowed_values: - if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]: + if self.meta_properties[key].choices: + 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)) return value @@ -705,6 +705,7 @@ class EventSettingsSerializer(SettingsSerializer): 'frontpage_subevent_ordering', 'event_list_type', 'event_list_available_only', + 'event_list_filters', 'event_calendar_future_only', 'frontpage_text', 'event_info_text', diff --git a/src/pretix/base/migrations/0250_eventmetaproperty_filter_public.py b/src/pretix/base/migrations/0250_eventmetaproperty_filter_public.py new file mode 100644 index 0000000000..19b4244036 --- /dev/null +++ b/src/pretix/base/migrations/0250_eventmetaproperty_filter_public.py @@ -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", + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index deaad5a811..1cfa84e1d4 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -20,7 +20,6 @@ # . # -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 # the Apache License 2.0 can be obtained at . # @@ -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 # 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. +import logging import os import string import uuid @@ -71,7 +71,7 @@ from pretix.base.validators import EventSlugBanlistValidator from pretix.helpers.database import GroupConcat from pretix.helpers.daterange import daterange 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 ..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 " "optional to set a value for individual dates") ) - allowed_values = models.TextField( + choices = models.JSONField( null=True, blank=True, + encoder=CustomJSONEncoder, 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( 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 " "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): super().full_clean(exclude, validate_unique) if self.default and self.required: 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: - ordering = ("name",) + ordering = ("position", "name",) + + @property + def choice_keys(self): + if self.choices: + return [v["key"] for v in self.choices] class EventMetaValue(LoggedModel): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index dc945ff38f..069db06aff 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -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.') ), }, + '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': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index b326eb8f16..c84aa1fec2 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -316,12 +316,12 @@ class EventMetaValueForm(forms.ModelForm): self.property = kwargs.pop('property') self.disabled = kwargs.pop('disabled') super().__init__(*args, **kwargs) - if self.property.allowed_values: + if self.property.choices: self.fields['value'] = forms.ChoiceField( label=self.property.name, choices=[ ('', _('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: self.fields['value'].label = self.property.name @@ -558,6 +558,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm): 'low_availability_percentage', 'event_list_type', 'event_list_available_only', + 'event_list_filters', 'event_calendar_future_only', 'frontpage_text', 'event_info_text', @@ -645,6 +646,7 @@ class EventSettingsForm(EventSettingsValidationMixin, SettingsForm): del self.fields['frontpage_subevent_ordering'] del self.fields['event_list_type'] del self.fields['event_list_available_only'] + del self.fields['event_list_filters'] del self.fields['event_calendar_future_only'] # create "virtual" fields for better UX when editing _asked and _required fields diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index befad313a4..631316532a 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -39,7 +39,7 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError 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.urls import reverse 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_scopes.forms import SafeModelChoiceField from i18nfield.forms import ( - I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, + I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, ) from phonenumber_field.formfields import PhoneNumberField 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 "")})' -class EventMetaPropertyForm(forms.ModelForm): +class EventMetaPropertyForm(I18nModelForm): class Meta: model = EventMetaProperty - fields = ['name', 'default', 'required', 'protected', 'allowed_values', 'filter_allowed'] + fields = ['name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed'] 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 Meta: diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index ef741e6756..6c3145d90c 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -393,12 +393,12 @@ class SubEventMetaValueForm(forms.ModelForm): self.default = kwargs.pop('default', None) self.disabled = kwargs.pop('disabled', False) super().__init__(*args, **kwargs) - if self.property.allowed_values: + if self.property.choices: self.fields['value'] = forms.ChoiceField( label=self.property.name, choices=[ ('', _('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: self.fields['value'].label = self.property.name diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 25afd4ba64..9b908c7fab 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -316,6 +316,9 @@ {% if sform.event_list_available_only %} {% bootstrap_field sform.event_list_available_only layout="control" %} {% endif %} + {% if sform.event_list_filters %} + {% bootstrap_field sform.event_list_filters layout="control" %} + {% endif %} {% if sform.event_calendar_future_only %} {% bootstrap_field sform.event_calendar_future_only layout="control" %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/properties.html b/src/pretix/control/templates/pretixcontrol/organizers/properties.html index 84a4b0da63..321458229f 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/properties.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/properties.html @@ -14,29 +14,56 @@ {% trans "Create a new property" %} - - - - - - - - - {% for p in properties %} + + {% csrf_token %} +
{% trans "Property" %}
+ - - + + + + + + - {% endfor %} - -
- - {{ p.name }} - - - - - {% trans "Property" %}
+ + + {% for p in properties %} + + + + {{ p.name }} + + + + {% if p.filter_allowed %} + + {% endif %} + + + {% if p.filter_public %} + + {% endif %} + + + {% if p.protected %} + + {% endif %} + + + + + + + + + + + + {% endfor %} + + + {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html index e3d93db05a..cf946a9731 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html @@ -1,5 +1,6 @@ {% extends "pretixcontrol/organizers/base.html" %} {% load i18n %} +{% load formset_tags %} {% load bootstrap3 %} {% block inner %} {% if property %} @@ -10,6 +11,78 @@
{% csrf_token %} {% bootstrap_form form layout="control" %} + +
+ +
+

{% trans "If you keep this empty, all input will be allowed." %}

+
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} + +
+ {% for form in formset %} + {% bootstrap_form_errors form %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} + {% bootstrap_field form.ORDER form_group_class="" layout="inline" %} +
+
+ {% bootstrap_field form.key layout='inline' form_group_class="" %} +
+
+ {% bootstrap_field form.label layout='inline' form_group_class="" %} +
+
+ + + +
+
+ {% endfor %} +
+
+
+ +
+
+
+
+
+
+
+
+{% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html index 801b500f98..b0f78055f4 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html @@ -53,6 +53,7 @@ + {% include "pretixpresale/fragment_event_list_filter.html" with request=request %} {% include "pretixpresale/fragment_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} {% if multiple_timezones %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html index 95f18ad02c..5dd18af0e8 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar_day.html @@ -46,6 +46,7 @@ + {% 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 %}
{% if has_before %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html index 2e17e5fec4..689a96cd78 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar_week.html @@ -58,6 +58,7 @@
+ {% 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 %}
{% if has_before %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/index.html b/src/pretix/presale/templates/pretixpresale/organizers/index.html index a315f26ba0..85795ced4b 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/index.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/index.html @@ -33,6 +33,7 @@
+ {% include "pretixpresale/fragment_event_list_filter.html" with request=request %} {% if events %}
- + {% if e.has_subevents %} {% trans "Tickets" %} {% elif e.presale_is_running and e.best_availability_state == 100 %} {% trans "Tickets" %} diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index c6deb10839..260c5cd002 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -34,6 +34,7 @@ import calendar import hashlib import math +import operator from collections import defaultdict from datetime import date, datetime, time, timedelta from functools import reduce @@ -46,11 +47,12 @@ from django.conf import settings from django.core.cache import caches from django.db.models import Exists, Max, Min, OuterRef, Prefetch, Q 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.templatetags.static import static from django.utils.decorators import method_decorator 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.views import View 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.multidomain.urlreverse import eventreverse +from pretix.presale.forms.organizer import EventListFilterForm from pretix.presale.ical import get_public_ical 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 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 = {} for i, item in enumerate(request.GET.items()): 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 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 = { p.name: p for p in request.organizer.meta_properties.filter( + Q(filter_allowed=True) | Q(filter_public=True), name__in=attrs.keys(), - filter_allowed=True, ) } + conditions = [] for i, item in enumerate(attrs.items()): attr, v = item @@ -136,13 +145,42 @@ def filter_qs_by_attr(qs, request): annotations['attr_{}_any'.format(i)] = Exists(emv_with_any_value) 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 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) qs = self.request.organizer.events.using(settings.DATABASE_REPLICA).filter(query) 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')), ) 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( - Q(Q(has_subevents=False) & Q( - Q(date_to__lt=now()) | Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) - )) | Q(Q(has_subevents=True) & Q( - Q(min_to__lt=now()) | Q(min_from__lt=now())) + Q(Q(has_subevents=False) & date_q) | Q( + Q(has_subevents=True) & Q(Q(min_to__lt=now()) | Q(min_from__lt=now())) ) ).annotate( order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'), ).order_by('-order_to') else: + date_q = Q(date_to__gte=now()) | (Q(date_to__isnull=True) & Q(date_from__gte=now())) qs = qs.filter( - Q(Q(has_subevents=False) & Q( - Q(date_to__gte=now()) | Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) - )) | Q(Q(has_subevents=True) & Q( + Q(Q(has_subevents=False) & date_q) | Q(Q(has_subevents=True) & Q( Q(max_to__gte=now()) | Q(max_from__gte=now())) ) ).annotate( order_from=Coalesce('min_from', 'date_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 def _set_month_to_next_subevent(self): @@ -387,7 +425,7 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView): return super().get(request, *args, **kwargs) def get_queryset(self): - return self._get_event_queryset() + return self._get_event_list_queryset() def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) @@ -398,6 +436,14 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView): event.min_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 diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 405c187adf..112034ded0 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -63,6 +63,7 @@ from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.daterange import daterange from pretix.helpers.thumb import get_thumbnail 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.event import ( get_grouped_items, item_group_by_category, @@ -464,6 +465,9 @@ class WidgetAPIProductList(EventListMixin, View): o = getattr(request, 'event', request.organizer) list_type = self.request.GET.get("style", o.settings.event_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"): # 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)) limit = 50 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) ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str) data['has_more_events'] = False @@ -629,7 +640,7 @@ class WidgetAPIProductList(EventListMixin, View): ] else: data['events'] = [] - qs = self._get_event_queryset() + qs = self._get_event_list_queryset() for event in qs: tz = ZoneInfo(event.cache.get_or_set('timezone', lambda: event.settings.timezone)) if event.has_subevents: diff --git a/src/pretix/static/pretixpresale/js/widget/widget.js b/src/pretix/static/pretixpresale/js/widget/widget.js index ddd680b53f..96a55ab2ca 100644 --- a/src/pretix/static/pretixpresale/js/widget/widget.js +++ b/src/pretix/static/pretixpresale/js/widget/widget.js @@ -1093,6 +1093,46 @@ Vue.component('pretix-widget-event-form', { } }); +Vue.component('pretix-widget-event-list-filter-field', { + template: ('
' + + '' + + '' + + '
'), + 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: ('
' + + '' + + '
'), +}); + Vue.component('pretix-widget-event-list-entry', { template: ('
' + '
{{ event.name }}
' @@ -1142,6 +1182,7 @@ Vue.component('pretix-widget-event-list', { + '{{ $root.name }}' + '
' + '
' + + '' + '' + '

' + '
'), @@ -1360,6 +1401,9 @@ Vue.component('pretix-widget-event-calendar', { + '' + '
' + // Filter + + '' + // Calendar navigation + '
' + // Filter + + '' + // Calendar navigation + '
' + '
' @@ -1671,6 +1718,7 @@ var shared_root_methods = { root.view = "weeks"; root.name = data.name; root.frontpage_text = data.frontpage_text; + root.meta_filter_fields = data.meta_filter_fields; } else if (data.days !== undefined) { root.days = data.days; root.date = null; @@ -1679,6 +1727,7 @@ var shared_root_methods = { root.view = "days"; root.name = data.name; root.frontpage_text = data.frontpage_text; + root.meta_filter_fields = data.meta_filter_fields; } else if (data.events !== undefined) { root.events = root.append_events && root.events ? root.events.concat(data.events) : data.events; root.append_events = false; @@ -1687,6 +1736,7 @@ var shared_root_methods = { root.name = data.name; root.frontpage_text = data.frontpage_text; root.has_more_events = data.has_more_events; + root.meta_filter_fields = data.meta_filter_fields; } else { root.view = "event"; root.name = data.name; @@ -1917,6 +1967,7 @@ var create_widget = function (element) { var skip_ssl = element.attributes["skip-ssl-check"] ? true : false; var disable_iframe = element.attributes["disable-iframe"] ? 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 filter = element.attributes.filter ? element.attributes.filter.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, vouchers_exist: false, disable_vouchers: disable_vouchers, + disable_filters: disable_filters, cart_exists: false, itemcount: 0, overlay: null, poweredby: "", has_seating_plan: false, has_seating_plan_waitinglist: false, + meta_filter_fields: [], } }, created: function () { diff --git a/src/pretix/static/pretixpresale/scss/_calendar.scss b/src/pretix/static/pretixpresale/scss/_calendar.scss index bb17635f05..b331c8dc35 100644 --- a/src/pretix/static/pretixpresale/scss/_calendar.scss +++ b/src/pretix/static/pretixpresale/scss/_calendar.scss @@ -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; + } + } + } +} diff --git a/src/pretix/static/pretixpresale/scss/widget.scss b/src/pretix/static/pretixpresale/scss/widget.scss index fbc5ab7926..589929559a 100644 --- a/src/pretix/static/pretixpresale/scss/widget.scss +++ b/src/pretix/static/pretixpresale/scss/widget.scss @@ -99,7 +99,7 @@ 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"); } - input[type="text"], input[type="number"] { + input[type="text"], input[type="number"], select { line-height: normal; border: 1px solid $input-border; 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 { 0% { transform: scale(0); diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index d2bf42177f..5903071eaa 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -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)) 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 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 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() team.can_change_organizer_settings = False team.save() diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index 569ba4dd75..d9a486abc6 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -256,7 +256,7 @@ def test_all_subevents_list_filter(token_client, organizer, event, subevent): @pytest.mark.django_db 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() team.can_change_organizer_settings = False team.save() diff --git a/src/tests/presale/test_organizer_page.py b/src/tests/presale/test_organizer_page.py index b8811f235a..053f281e07 100644 --- a/src/tests/presale/test_organizer_page.py +++ b/src/tests/presale/test_organizer_page.py @@ -72,6 +72,21 @@ def test_attributes_on_page(env, client): r = client.get('/mrmcd/?attr[loc]=HH') 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.save() r = client.get('/mrmcd/?attr[loc]=MA') diff --git a/src/tests/presale/test_widget.py b/src/tests/presale/test_widget.py index 590183ddc7..a616d2b09a 100644 --- a/src/tests/presale/test_widget.py +++ b/src/tests/presale/test_widget.py @@ -605,6 +605,7 @@ class WidgetCartTest(CartTestMixin, TestCase): data = json.loads(response.content.decode()) assert data == { 'list_type': 'list', + 'meta_filter_fields': [], 'name': '30C3', 'frontpage_text': '', 'poweredby': 'ticketing powered by pretix', @@ -633,6 +634,7 @@ class WidgetCartTest(CartTestMixin, TestCase): data = json.loads(response.content.decode()) assert data == { 'list_type': 'calendar', + 'meta_filter_fields': [], 'date': '2019-01-01', 'name': '30C3', 'frontpage_text': '', @@ -708,6 +710,7 @@ class WidgetCartTest(CartTestMixin, TestCase): data = json.loads(response.content.decode()) assert data == { 'list_type': 'week', + 'meta_filter_fields': [], 'name': '30C3', 'frontpage_text': '', 'week': [2019, 1], @@ -769,9 +772,72 @@ class WidgetCartTest(CartTestMixin, TestCase): 'event_url': 'http://example.com/ccc/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): self.event.has_subevents = True self.event.settings.timezone = 'Europe/Berlin' @@ -794,6 +860,7 @@ class WidgetCartTest(CartTestMixin, TestCase): assert data == { 'date': '2019-01-01', 'list_type': 'calendar', + 'meta_filter_fields': [], 'poweredby': 'ticketing powered by pretix', 'weeks': [ [None,