diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index d9b1c02065..2ff15c6b3d 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1,16 +1,22 @@ from datetime import datetime, time +from decimal import Decimal from urllib.parse import urlencode from django import forms from django.apps import apps -from django.db.models import Exists, F, OuterRef, Q +from django.conf import settings +from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet from django.db.models.functions import Coalesce, ExtractWeekDay from django.urls import reverse, reverse_lazy +from django.utils.formats import date_format, localize from django.utils.functional import cached_property from django.utils.timezone import get_current_timezone, make_aware, now -from django.utils.translation import gettext_lazy as _, pgettext_lazy +from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy -from pretix.base.forms.widgets import DatePickerWidget +from pretix.base.channels import get_all_sales_channels +from pretix.base.forms.widgets import ( + DatePickerWidget, SplitDateTimePickerWidget, +) from pretix.base.models import ( Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, Question, @@ -19,7 +25,9 @@ from pretix.base.models import ( from pretix.base.signals import register_payment_providers from pretix.control.forms.widgets import Select2 from pretix.control.signals import order_search_filter_q +from pretix.helpers.countries import CachedCountries from pretix.helpers.database import FixedOrderBy, rolledback_transaction +from pretix.helpers.dicts import move_to_end from pretix.helpers.i18n import i18ncomp PAYMENT_PROVIDERS = [] @@ -83,6 +91,38 @@ class FilterForm(forms.Form): else: return self.orders[o] + def filter_to_strings(self): + string = [] + for k, f in self.fields.items(): + v = self.cleaned_data.get(k) + if v is None or (isinstance(v, (list, str, QuerySet)) and len(v) == 0): + continue + if k == "saveas": + continue + + if isinstance(v, bool): + val = _('Yes') if v else _('No') + elif isinstance(v, QuerySet): + q = ['"' + str(m) + '"' for m in v] + if not q: + continue + val = ' or '.join(q) + elif isinstance(v, Model): + val = '"' + str(v) + '"' + elif isinstance(f, forms.MultipleChoiceField): + valdict = dict(f.choices) + val = ' or '.join([str(valdict.get(m)) for m in v]) + elif isinstance(f, forms.ChoiceField): + val = str(dict(f.choices).get(v)) + elif isinstance(v, datetime): + val = date_format(v, 'SHORT_DATETIME_FORMAT') + elif isinstance(v, Decimal): + val = localize(v) + else: + val = v + string.append('{}: {}'.format(f.label, val)) + return string + class OrderFilterForm(FilterForm): query = forms.CharField( @@ -104,21 +144,29 @@ class OrderFilterForm(FilterForm): label=_('Order status'), choices=( ('', _('All orders')), - (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')), - (Order.STATUS_PENDING, _('Pending')), - ('o', _('Pending (overdue)')), - (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')), - (Order.STATUS_EXPIRED, _('Expired')), - (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')), - (Order.STATUS_CANCELED, _('Canceled')), - ('cp', _('Canceled (or with paid fee)')), - ('na', _('Approved, payment pending')), - ('pa', _('Approval pending')), - ('overpaid', _('Overpaid')), - ('underpaid', _('Underpaid')), - ('pendingpaid', _('Pending (but fully paid)')), + (_('Valid orders'), ( + (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')), + (Order.STATUS_PENDING, _('Pending')), + (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')), + )), + (_('Cancellations'), ( + (Order.STATUS_CANCELED, _('Canceled')), + ('cp', _('Canceled (or with paid fee)')), + ('rc', _('Cancellation requested')), + )), + (_('Payment process'), ( + (Order.STATUS_EXPIRED, _('Expired')), + (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')), + ('o', _('Pending (overdue)')), + ('overpaid', _('Overpaid')), + ('underpaid', _('Underpaid')), + ('pendingpaid', _('Pending (but fully paid)')), + )), + (_('Approval process'), ( + ('na', _('Approved, payment pending')), + ('pa', _('Approval pending')), + )), ('testmode', _('Test mode')), - ('rc', _('Cancellation requested')), ), required=False, ) @@ -343,6 +391,237 @@ class EventOrderFilterForm(OrderFilterForm): return qs +class FilterNullBooleanSelect(forms.NullBooleanSelect): + def __init__(self, attrs=None): + choices = ( + ('unknown', _('All')), + ('true', _('Yes')), + ('false', _('No')), + ) + super(forms.NullBooleanSelect, self).__init__(attrs, choices) + + +class EventOrderExpertFilterForm(EventOrderFilterForm): + subevents_from = forms.SplitDateTimeField( + widget=SplitDateTimePickerWidget(attrs={ + }), + label=pgettext_lazy('subevent', 'All dates starting at or after'), + required=False, + ) + subevents_to = forms.SplitDateTimeField( + widget=SplitDateTimePickerWidget(attrs={ + }), + label=pgettext_lazy('subevent', 'All dates starting before'), + required=False, + ) + created_from = forms.SplitDateTimeField( + widget=SplitDateTimePickerWidget(attrs={ + }), + label=_('Order placed at or after'), + required=False, + ) + created_to = forms.SplitDateTimeField( + widget=SplitDateTimePickerWidget(attrs={ + }), + label=_('Order placed before'), + required=False, + ) + email = forms.CharField( + required=False, + label=_('E-mail address') + ) + comment = forms.CharField( + required=False, + label=_('Comment') + ) + locale = forms.ChoiceField( + required=False, + label=_('Locale'), + choices=settings.LANGUAGES + ) + email_known_to_work = forms.NullBooleanField( + required=False, + widget=FilterNullBooleanSelect, + label=_('E-mail address verified'), + ) + total = forms.DecimalField( + localize=True, + required=False, + label=_('Total amount'), + ) + sales_channel = forms.ChoiceField( + label=_('Sales channel'), + required=False, + choices=[('', '')] + [ + (k, v.verbose_name) for k, v in get_all_sales_channels().items() + ] + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + del self.fields['query'] + del self.fields['question'] + del self.fields['answer'] + del self.fields['ordering'] + if not self.event.has_subevents: + del self.fields['subevents_from'] + del self.fields['subevents_to'] + + locale_names = dict(settings.LANGUAGES) + self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales] + + move_to_end(self.fields, 'item') + move_to_end(self.fields, 'provider') + + self.fields['invoice_address_company'] = forms.CharField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('Company') + ) + self.fields['invoice_address_name'] = forms.CharField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('Name') + ) + self.fields['invoice_address_street'] = forms.CharField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('Address') + ) + self.fields['invoice_address_zipcode'] = forms.CharField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('ZIP code'), + help_text=_('Exact matches only') + ) + self.fields['invoice_address_city'] = forms.CharField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('City'), + help_text=_('Exact matches only') + ) + self.fields['invoice_address_country'] = forms.ChoiceField( + required=False, + label=gettext('Invoice address') + ': ' + gettext('Country'), + choices=[('', '')] + list(CachedCountries()) + ) + self.fields['attendee_name'] = forms.CharField( + required=False, + label=_('Attendee name') + ) + self.fields['attendee_email'] = forms.CharField( + required=False, + label=_('Attendee e-mail address') + ) + self.fields['attendee_address_company'] = forms.CharField( + required=False, + label=gettext('Attendee address') + ': ' + gettext('Company') + ) + self.fields['attendee_address_street'] = forms.CharField( + required=False, + label=gettext('Attendee address') + ': ' + gettext('Address') + ) + self.fields['attendee_address_zipcode'] = forms.CharField( + required=False, + label=gettext('Attendee address') + ': ' + gettext('ZIP code'), + help_text=_('Exact matches only') + ) + self.fields['attendee_address_city'] = forms.CharField( + required=False, + label=gettext('Attendee address') + ': ' + gettext('City'), + help_text=_('Exact matches only') + ) + self.fields['attendee_address_country'] = forms.ChoiceField( + required=False, + label=gettext('Attendee address') + ': ' + gettext('Country'), + choices=[('', '')] + list(CachedCountries()) + ) + self.fields['ticket_secret'] = forms.CharField( + label=_('Ticket secret'), + required=False + ) + for q in self.event.questions.all(): + self.fields['question_{}'.format(q.pk)] = forms.CharField( + label=q.question, + required=False, + help_text=_('Exact matches only') + ) + + def filter_qs(self, qs): + fdata = self.cleaned_data + qs = super().filter_qs(qs) + + if fdata.get('subevents_from'): + qs = qs.filter( + all_positions__subevent__date_from__gte=fdata.get('subevents_from'), + all_positions__canceled=False + ).distinct() + if fdata.get('subevents_to'): + qs = qs.filter( + all_positions__subevent__date_from__lt=fdata.get('subevents_to'), + all_positions__canceled=False + ).distinct() + if fdata.get('email'): + qs = qs.filter( + email__icontains=fdata.get('email') + ) + if fdata.get('created_from'): + qs = qs.filter(datetime__gte=fdata.get('created_from')) + if fdata.get('created_to'): + qs = qs.filter(datetime__gte=fdata.get('created_to')) + if fdata.get('comment'): + qs = qs.filter(comment__icontains=fdata.get('comment')) + if fdata.get('sales_channel'): + qs = qs.filter(sales_channel=fdata.get('sales_channel')) + if fdata.get('total'): + qs = qs.filter(total=fdata.get('total')) + if fdata.get('email_known_to_work') is not None: + qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work')) + if fdata.get('locale'): + qs = qs.filter(locale=fdata.get('locale')) + if fdata.get('invoice_address_company'): + qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company')) + if fdata.get('invoice_address_name'): + qs = qs.filter(invoice_address__name_cached__icontains=fdata.get('invoice_address_name')) + if fdata.get('invoice_address_street'): + qs = qs.filter(invoice_address__street__icontains=fdata.get('invoice_address_street')) + if fdata.get('invoice_address_zipcode'): + qs = qs.filter(invoice_address__zipcode__iexact=fdata.get('invoice_address_zipcode')) + if fdata.get('invoice_address_city'): + qs = qs.filter(invoice_address__city__iexact=fdata.get('invoice_address_city')) + if fdata.get('invoice_address_country'): + qs = qs.filter(invoice_address__country=fdata.get('invoice_address_country')) + if fdata.get('attendee_name'): + qs = qs.filter( + all_positions__attendee_name_cached__icontains=fdata.get('attendee_name') + ) + if fdata.get('attendee_address_company'): + qs = qs.filter( + all_positions__company__icontains=fdata.get('attendee_address_company') + ).distinct() + if fdata.get('attendee_address_street'): + qs = qs.filter( + all_positions__street__icontains=fdata.get('attendee_address_street') + ).distinct() + if fdata.get('attendee_address_city'): + qs = qs.filter( + all_positions__city__iexact=fdata.get('attendee_address_city') + ).distinct() + if fdata.get('attendee_address_country'): + qs = qs.filter( + all_positions__country=fdata.get('attendee_address_country') + ).distinct() + if fdata.get('ticket_secret'): + qs = qs.filter( + all_positions__secret__icontains=fdata.get('ticket_secret') + ).distinct() + for q in self.event.questions.all(): + if fdata.get(f'question_{q.pk}'): + answers = QuestionAnswer.objects.filter( + question_id=q.pk, + orderposition__order_id=OuterRef('pk'), + answer__iexact=fdata.get(f'question_{q.pk}') + ) + qs = qs.annotate(**{f'q_{q.pk}': Exists(answers)}).filter(**{f'q_{q.pk}': True}) + + return qs + + class OrderSearchFilterForm(OrderFilterForm): orders = {'code': 'code', 'email': 'email', 'total': 'total', 'datetime': 'datetime', 'status': 'status', diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 5d59d368f7..e7ff71f281 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -176,7 +176,7 @@ def get_event_navigation(request: HttpRequest): 'event': request.event.slug, 'organizer': request.event.organizer.slug, }), - 'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name, + 'active': url.url_name in ('event.orders', 'event.order', 'event.orders.search') or "event.order." in url.url_name, }, { 'label': _('Overview'), diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py index 1c16d4ec85..14fb2cce8b 100644 --- a/src/pretix/control/signals.py +++ b/src/pretix/control/signals.py @@ -323,3 +323,19 @@ this is not an Event signal and will be called even if your plugin is not active event if the search is performed within an event, and ``None`` otherwise. The search query will be passed as ``query``. """ + +order_search_forms = EventPluginSignal( + providing_args=['request'] +) +""" +This signal allows you to return additional forms that should be rendered in the advanced order search. +You are passed ``request`` argument and are expected to return an instance of a form class that you bind +yourself when appropriate. Your form will be executed as part of the standard validation and rendering +cycle and rendered using default bootstrap styles. + +You are required to set ``prefix`` on your form instance. You are required to implement a ``filter_qs(queryset)`` +method on your form that returns a new, filtered query set. You are required to implement a ``filter_to_strings()`` +method on your form that returns a list of strings describing the currently active filters. + +As with all plugin signals, the ``sender`` keyword argument will contain the event. +""" diff --git a/src/pretix/control/templates/pretixcontrol/orders/index.html b/src/pretix/control/templates/pretixcontrol/orders/index.html index 5631300e3c..4aae96bbfb 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/index.html +++ b/src/pretix/control/templates/pretixcontrol/orders/index.html @@ -7,7 +7,7 @@ {% block title %}{% trans "Orders" %}{% endblock %} {% block content %}

{% trans "Orders" %}

- {% if not filter_form.filtered and orders|length == 0 %} + {% if not filter_form.filtered and orders|length == 0 and not filter_strings %}

{% blocktrans trimmed %} @@ -21,57 +21,72 @@ {% trans "Take your shop live" %} {% else %} - + {% trans "Go to the ticket shop" %} {% endif %}

{% else %} -
-
-
- - - - -
-
-
-
- {% bootstrap_field filter_form.status layout='inline' %} -
- {% if request.event.has_subevents %} -
- {% bootstrap_field filter_form.item layout='inline' %} -
-
- {% bootstrap_field filter_form.subevent layout='inline' %} -
-
- {% bootstrap_field filter_form.provider layout='inline' %} -
- {% else %} -
- {% bootstrap_field filter_form.item layout='inline' %} -
-
- {% bootstrap_field filter_form.provider layout='inline' %} -
- {% endif %} -
- {% bootstrap_field filter_form.query layout='inline' %} -
-
- - -
-
-
+ + +
+
+ {% bootstrap_field filter_form.status layout='inline' %} +
+ {% if request.event.has_subevents %} +
+ {% bootstrap_field filter_form.item layout='inline' %} +
+
+ {% bootstrap_field filter_form.subevent layout='inline' %} +
+
+ {% bootstrap_field filter_form.provider layout='inline' %} +
+ {% else %} +
+ {% bootstrap_field filter_form.item layout='inline' %} +
+
+ {% bootstrap_field filter_form.provider layout='inline' %} +
+ {% endif %} +
+ {% bootstrap_field filter_form.query layout='inline' %} +
+
+ +
+
+ + + +
+
+ + {% endif %} {% if filter_form.is_valid and filter_form.cleaned_data.question %}

diff --git a/src/pretix/control/templates/pretixcontrol/orders/search.html b/src/pretix/control/templates/pretixcontrol/orders/search.html new file mode 100644 index 0000000000..2905bfef04 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/orders/search.html @@ -0,0 +1,23 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load money %} +{% load bootstrap3 %} +{% block title %}{% trans "Order search" %}{% endblock %} +{% block content %} +

{% trans "Order search" %}

+
+ {% for f in forms %} + {% bootstrap_form_errors f layout='control' %} + {% for field in f %} + {% bootstrap_field field layout='control' %} + {% endfor %} + {% endfor %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 7868e5cd79..cb5f3141f0 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -283,6 +283,7 @@ urlpatterns = [ url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'), url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'), + url(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'), url(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'), url(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'), url(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 06256f3861..953b5c2979 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -74,7 +74,8 @@ from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.mixins import OrderQuestionsViewMixin from pretix.base.views.tasks import AsyncAction from pretix.control.forms.filter import ( - EventOrderFilterForm, OverviewFilterForm, RefundFilterForm, + EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm, + RefundFilterForm, ) from pretix.control.forms.orders import ( CancelForm, CommentForm, ConfirmPaymentForm, EventCancelForm, ExporterForm, @@ -84,6 +85,7 @@ from pretix.control.forms.orders import ( OrderRefundForm, OtherOperationsForm, ) from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.signals import order_search_forms from pretix.control.views import PaginationMixin from pretix.helpers.safedownload import check_token from pretix.presale.signals import question_form_fields @@ -91,7 +93,31 @@ from pretix.presale.signals import question_form_fields logger = logging.getLogger(__name__) -class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView): +class OrderSearchMixin: + def get_forms(self): + f = [ + EventOrderExpertFilterForm( + data=self.request.GET, + event=self.request.event, + prefix='expert', + ) + ] + for recv, resp in order_search_forms.send(sender=self.request.event, request=self.request): + f.append(resp) + return f + + +class OrderSearch(OrderSearchMixin, EventPermissionRequiredMixin, TemplateView): + template_name = 'pretixcontrol/orders/search.html' + permission = 'can_view_orders' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['forms'] = self.get_forms() + return ctx + + +class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView): model = Order context_object_name = 'orders' template_name = 'pretixcontrol/orders/index.html' @@ -105,12 +131,21 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView): if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) + for f in self.get_forms(): + if any(k.startswith(f.prefix) for k in self.request.GET.keys()) and f.is_valid(): + qs = f.filter_qs(qs) + return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['filter_form'] = self.filter_form + ctx['filter_strings'] = [] + for f in self.get_forms(): + if any(k.startswith(f.prefix) for k in self.request.GET.keys()) and f.is_valid(): + ctx['filter_strings'] += f.filter_to_strings() + # Only compute this annotations for this page (query optimization) s = OrderPosition.objects.filter( order=OuterRef('pk') diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index d36fb9a5bc..c88d358453 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -163,6 +163,7 @@ def logged_in_client(client, event): ('/control/event/{orga}/{event}/orders/overview/', 200), ('/control/event/{orga}/{event}/orders/export/', 200), ('/control/event/{orga}/{event}/orders/go', 302), + ('/control/event/{orga}/{event}/orders/search', 200), ('/control/event/{orga}/{event}/orders/', 200), ('/control/event/{orga}/{event}/waitinglist/', 200), ('/control/event/{orga}/{event}/waitinglist/auto_assign', 405),