diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 422eb5d23b..4b64959c1d 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -217,6 +217,9 @@ List of all invoices :query boolean is_cancellation: If set to ``true`` or ``false``, only invoices with this value for the field ``is_cancellation`` will be returned. :query string order: If set, only invoices belonging to the order with the given order code will be returned. + This parameter may be given multiple times. In this case, all invoices matching one of the inputs will be returned. + :query string number: If set, only invoices with the given invoice number will be returned. + This parameter may be given multiple times. In this case, all invoices matching one of the inputs will be returned. :query string refers: If set, only invoices referring to the given invoice will be returned. :query string locale: If set, only invoices with the given locale will be returned. :query string ordering: Manually set the ordering of results. Valid fields to be used are ``date`` and diff --git a/src/pretix/api/filters.py b/src/pretix/api/filters.py new file mode 100644 index 0000000000..e9217139f7 --- /dev/null +++ b/src/pretix/api/filters.py @@ -0,0 +1,82 @@ +# +# 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 . +# +# 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 +# . +# +from django import forms +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.db.models.constants import LOOKUP_SEP +from django.forms import MultipleChoiceField +from django_filters import Filter +from django_filters.conf import settings + + +class MultipleCharField(forms.CharField): + widget = forms.MultipleHiddenInput + + def to_python(self, value): + if not value: + return [] + elif not isinstance(value, (list, tuple)): + raise ValidationError( + MultipleChoiceField.default_error_messages["invalid_list"], code="invalid_list" + ) + return [str(val) for val in value] + + +class MultipleCharFilter(Filter): + """ + This filter performs OR(by default) or AND(using conjoined=True) query + on the selected inputs. + """ + + field_class = MultipleCharField + + def __init__(self, *args, **kwargs): + self.conjoined = kwargs.pop("conjoined", False) + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if not value: + # Even though not a noop, no point filtering if empty. + return qs + + if not self.conjoined: + q = Q() + for v in set(value): + predicate = self.get_filter_predicate(v) + if self.conjoined: + qs = self.get_method(qs)(**predicate) + else: + q |= Q(**predicate) + + if not self.conjoined: + qs = self.get_method(qs)(q) + + return qs.distinct() if self.distinct else qs + + def get_filter_predicate(self, v): + name = self.field_name + if name and self.lookup_expr != settings.DEFAULT_LOOKUP_EXPR: + name = LOOKUP_SEP.join([name, self.lookup_expr]) + try: + return {name: getattr(v, self.field.to_field_name)} + except (AttributeError, TypeError): + return {name: v} diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 5ca5876c68..050908e33b 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -49,6 +49,7 @@ from rest_framework.mixins import CreateModelMixin from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response +from pretix.api.filters import MultipleCharFilter from pretix.api.models import OAuthAccessToken from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.order import ( @@ -1825,17 +1826,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): with scopes_disabled(): class InvoiceFilter(FilterSet): refers = django_filters.CharFilter(method='refers_qs') - number = django_filters.CharFilter(method='nr_qs') - order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') + number = MultipleCharFilter(field_name='nr', lookup_expr='iexact') + order = MultipleCharFilter(field_name='order', lookup_expr='code__iexact') def refers_qs(self, queryset, name, value): return queryset.annotate( refers_nr=Concat('refers__prefix', 'refers__invoice_no') ).filter(refers_nr__iexact=value) - def nr_qs(self, queryset, name, value): - return queryset.filter(nr__iexact=value) - class Meta: model = Invoice fields = ['order', 'number', 'is_cancellation', 'refers', 'locale'] diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 4be83047ac..096452dda1 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -311,6 +311,20 @@ def test_invoice_list(token_client, organizer, event, order, item, invoice): assert [] == resp.data['results'] +@pytest.mark.django_db +def test_invoice_list_multi_filter(token_client, organizer, event, order, order2, item, invoice, invoice2): + order2.event = event + order2.save() + invoice2.event = event + invoice2.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO'.format(organizer.slug, event.slug)) + assert len(resp.data['results']) == 1 + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=BAR'.format(organizer.slug, event.slug)) + assert len(resp.data['results']) == 1 + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO&order=BAR'.format(organizer.slug, event.slug)) + assert len(resp.data['results']) == 2 + + @pytest.mark.django_db def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2): resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))