API: Generalize concept of including/excluding/expanding fields

This commit is contained in:
Raphael Michel
2025-06-24 09:52:32 +02:00
parent f6df03c427
commit a7edb16fc0
8 changed files with 279 additions and 58 deletions

View File

@@ -203,9 +203,35 @@ Query parameters
Most list endpoints allow a filtering of the results using query parameters. In this case, booleans should be passed Most list endpoints allow a filtering of the results using query parameters. In this case, booleans should be passed
as the string values ``true`` and ``false``. as the string values ``true`` and ``false``.
Ordering
--------
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
fields. Prepend a ``-`` to the field name to reverse the sort order. fields. Prepend a ``-`` to the field name to reverse the sort order.
Filtering and expanding fields
------------------------------
On many endpoints, you can modify what fields are being returned:
- Using the ``include`` query parameter, you can chose which fields will be returned as part of the response.
For example, if you pass ``include=code&include=email`` to the list of orders, you will receive a list of only
order codes and email addresses.
- Using the ``exclude`` query parameter, you can chose which fields will not be returned as part of the response.
For example, if you pass ``exclude=payments&exclude=refunds`` to the list of orders, you will receive a list
without the payment and refund objects.
- Using the ``expand`` query parameter, you can chose which fields will be expanded into full objects. For example,
if you pass ``expand=voucher`` to the list of order positions, the response will contain a full voucher object
instead of just the ID. If you do not have permission to view vouchers, a 403 status code is returned.
For performance reasons, this option is only available for a limited number of fields that are noted as
"expandable" in the documentation of the respective object.
In all of these, you can use dotted notation to address fields of sub-objects, such as ``positions.checkins.gate``.
These options are not available everywhere as we are slowly rolling them out throughout the codebase. Please check
the individual endpoint documentation for availability.
Idempotency Idempotency
----------- -----------

View File

@@ -61,6 +61,8 @@ Endpoints
:query page: The page number in case of a multi-page result set, default is 1 :query page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and :query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
``name``. Default: ``slug``. ``name``. Default: ``slug``.
:query string include: Limit the output to the given field. Can be passed multiple times.
:query string exclude: Exclude a field from the output. Can be passed multiple times.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure

View File

@@ -23,7 +23,7 @@ import json
from django.db.models import prefetch_related_objects from django.db.models import prefetch_related_objects
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
class AsymmetricField(serializers.Field): class AsymmetricField(serializers.Field):
@@ -135,3 +135,117 @@ class SalesChannelMigrationMixin:
else: else:
value["sales_channels"] = value["limit_sales_channels"] value["sales_channels"] = value["limit_sales_channels"]
return value return value
class ConfigurableSerializerMixin:
expand_fields = {}
def get_exclude_requests(self):
if hasattr(self, "initial_data"):
# Do not support include requests when the serializer is used for writing
# TODO: think about this
return set()
if 'exclude' in self.context:
return self.context['exclude']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('exclude')
raise TypeError("Could not discover list of fields to exclude")
def get_include_requests(self):
if hasattr(self, "initial_data"):
# Do not support include requests when the serializer is used for writing
# TODO: think about this
return set()
if 'include' in self.context:
return self.context['include']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('include')
raise TypeError("Could not discover list of fields to include")
def get_expand_requests(self):
if hasattr(self, "initial_data"):
# Do not support expand requests when the serializer is used for writing
# TODO: think about this
return set()
if 'expand' in self.context:
return self.context['expand']
elif 'request' in self.context:
return self.context['request'].query_params.getlist('expand')
raise TypeError("Could not discover list of fields to expand")
def _exclude_field(self, serializer, path):
if path[0] not in serializer.fields:
return # field does not exist, nothing to do
if len(path) == 1:
del serializer.fields[path[0]]
elif len(path) >= 2 and hasattr(serializer.fields[path[0]], "child"):
self._exclude_field(serializer.fields[path[0]].child, path[1:])
elif len(path) >= 2 and isinstance(serializer.fields[path[0]], serializers.Serializer):
self._exclude_field(serializer.fields[path[0]], path[1:])
def _filter_fields_to_included(self, serializer, includes):
any_field_remaining = False
for fname, field in list(serializer.fields.items()):
if fname in includes:
any_field_remaining = True
continue
elif hasattr(field, 'child'): # Nested list serializers
child_includes = {i.removeprefix(f'{fname}.') for i in includes if i.startswith(f'{fname}.')}
if child_includes and self._filter_fields_to_included(field.child, child_includes):
any_field_remaining = True
continue
serializer.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
child_includes = {i.removeprefix(f'{fname}.') for i in includes if i.startswith(f'{fname}.')}
if child_includes and self._filter_fields_to_included(field, child_includes):
any_field_remaining = True
continue
serializer.fields.pop(fname)
else:
serializer.fields.pop(fname)
return any_field_remaining
def _expand_field(self, serializer, path, original_field):
if path[0] not in serializer.fields or not self.is_field_expandable(original_field):
return False # field does not exist, nothing to do
if len(path) == 1:
serializer.fields[path[0]] = self.get_expand_serializer(original_field)
return True
elif len(path) >= 2 and hasattr(serializer.fields[path[0]], "child"):
return self._expand_field(serializer.fields[path[0]].child, path[1:], original_field)
elif len(path) >= 2 and isinstance(serializer.fields[path[0]], serializers.Serializer):
return self._expand_field(serializer.fields[path[0]], path[1:], original_field)
def is_field_expandable(self, field):
return field in self.expand_fields
def get_expand_serializer(self, field):
from pretix.base.models import Device, TeamAPIToken
ef = self.expand_fields[field]
if "permission" in ef:
request = self.context["request"]
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(request.organizer, request.event, ef["permission"], request=request):
raise PermissionDenied(f"No permission to expand field {field}")
return ef["serializer"](
read_only=True,
context=self.context,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
expanded = False
for expand in sorted(list(self.get_expand_requests())):
expanded = expanded or self._expand_field(self, expand.split('.'), expand)
includes = set(self.get_include_requests())
if includes:
self._filter_fields_to_included(self, includes)
for exclude_field in self.get_exclude_requests():
self._exclude_field(self, exclude_field.split('.'))

View File

@@ -23,15 +23,19 @@ from django.utils.translation import gettext as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers import ConfigurableSerializerMixin
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList from pretix.base.models import Checkin, CheckinList
class CheckinListSerializer(I18nAwareModelSerializer): class CheckinListSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True) checkin_count = serializers.IntegerField(read_only=True)
position_count = serializers.IntegerField(read_only=True) position_count = serializers.IntegerField(read_only=True)
expand_fields = {
"subevent": SubEventSerializer,
}
class Meta: class Meta:
model = CheckinList model = CheckinList
@@ -42,17 +46,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
event = self.context['event'] event = self.context['event']

View File

@@ -40,12 +40,15 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from pretix.api.serializers import CompatibleJSONField from pretix.api.serializers import (
CompatibleJSONField, ConfigurableSerializerMixin,
)
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer, InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
) )
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.api.signals import order_api_details, orderposition_api_details from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
@@ -175,7 +178,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
r = super().to_representation(instance) r = super().to_representation(instance)
if r['answer'].startswith('file://') and instance.orderposition: if r.get('answer') and r.get('answer').startswith('file://') and instance.orderposition:
r['answer'] = reverse('api-v1:orderposition-answer', kwargs={ r['answer'] = reverse('api-v1:orderposition-answer', kwargs={
'organizer': instance.orderposition.order.event.organizer.slug, 'organizer': instance.orderposition.order.event.organizer.slug,
'event': instance.orderposition.order.event.slug, 'event': instance.orderposition.order.event.slug,
@@ -757,7 +760,7 @@ class OrderPluginDataField(serializers.Field):
return d return d
class OrderSerializer(I18nAwareModelSerializer): class OrderSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True) event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True) invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True) positions = OrderPositionSerializer(many=True, read_only=True)
@@ -775,6 +778,12 @@ class OrderSerializer(I18nAwareModelSerializer):
required=False, required=False,
) )
plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True) plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True)
expand_fields = {
"positions.voucher": {
"serializer": VoucherSerializer,
"permission": "can_view_vouchers",
},
}
class Meta: class Meta:
model = Order model = Order
@@ -793,47 +802,14 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if "organizer" in self.context: if "sales_channel" in self.fields:
self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all() if "organizer" in self.context:
else: self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all()
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all() else:
if not self.context['pdf_data']: self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
if not self.context['pdf_data'] and "positions" in self.fields:
self.fields['positions'].child.fields.pop('pdf_data', None) self.fields['positions'].child.fields.pop('pdf_data', None)
includes = set(self.context['include'])
if includes:
for fname, field in list(self.fields.items()):
if fname in includes:
continue
elif hasattr(field, 'child'): # Nested list serializers
found_any = False
for childfname, childfield in list(field.child.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.child.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
found_any = False
for childfname, childfield in list(field.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
else:
self.fields.pop(fname)
for exclude_field in self.context['exclude']:
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate_locale(self, l): def validate_locale(self, l):
if l not in set(k for k in self.instance.event.settings.locales): if l not in set(k for k in self.instance.event.settings.locales):
raise ValidationError('"{}" is not a supported locale for this event.'.format(l)) raise ValidationError('"{}" is not a supported locale for this event.'.format(l))

View File

@@ -31,7 +31,7 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.auth.devicesecurity import get_all_security_profiles from pretix.api.auth.devicesecurity import get_all_security_profiles
from pretix.api.serializers import AsymmetricField from pretix.api.serializers import AsymmetricField, ConfigurableSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer from pretix.api.serializers.settings import SettingsSerializer
@@ -51,7 +51,7 @@ from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OrganizerSerializer(I18nAwareModelSerializer): class OrganizerSerializer(ConfigurableSerializer, I18nAwareModelSerializer):
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True) public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
def get_organizer_url(self, organizer): def get_organizer_url(self, organizer):

View File

@@ -31,6 +31,7 @@ from django.utils.timezone import now
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from stripe import error from stripe import error
from tests.api.utils import _test_configurable_serializer
from tests.plugins.stripe.test_checkout import apple_domain_create from tests.plugins.stripe.test_checkout import apple_domain_create
from tests.plugins.stripe.test_provider import MockedCharge from tests.plugins.stripe.test_provider import MockedCharge
@@ -400,13 +401,18 @@ def test_order_list_filter_subevent_date(token_client, device, organizer, event,
@pytest.mark.django_db @pytest.mark.django_db
def test_order_list(token_client, organizer, event, order, item, taxrule, question, device): def test_order_list(token_client, organizer, event, order, item, team, taxrule, question, device):
res = dict(TEST_ORDER_RES) res = dict(TEST_ORDER_RES)
with scopes_disabled(): with scopes_disabled():
voucher = event.vouchers.create(code="FOO")
opos = order.positions.first()
opos.voucher = voucher
opos.save()
res["positions"][0]["id"] = order.positions.first().pk res["positions"][0]["id"] = order.positions.first().pk
res["fees"][0]["id"] = order.fees.first().pk res["fees"][0]["id"] = order.fees.first().pk
res["positions"][0]["print_logs"][0]["id"] = order.positions.first().print_logs.first().pk res["positions"][0]["print_logs"][0]["id"] = order.positions.first().print_logs.first().pk
res["positions"][0]["print_logs"][0]["device_id"] = device.device_id res["positions"][0]["print_logs"][0]["device_id"] = device.device_id
res["positions"][0]["voucher"] = voucher.pk
res["positions"][0]["item"] = item.pk res["positions"][0]["item"] = item.pk
res["positions"][0]["answers"][0]["question"] = question.pk res["positions"][0]["answers"][0]["question"] = question.pk
res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z') res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z')
@@ -514,6 +520,22 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.data['results'][0]['fees']) == 2 assert len(resp.data['results'][0]['fees']) == 2
_test_configurable_serializer(
token_client,
"/api/v1/organizers/{}/events/{}/orders/".format(organizer.slug, event.slug),
[
"status", "invoice_address.company", "fees.value", "payments.state",
"positions.print_logs.type", "positions.answers.answer"
],
expands=["positions.voucher"],
)
team.can_view_vouchers = False
team.save()
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?expand=positions.voucher'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert resp.data == "No permission to expand field positions.voucher"
@pytest.mark.django_db @pytest.mark.django_db
def test_order_detail(token_client, organizer, event, order, item, taxrule, question): def test_order_detail(token_client, organizer, event, order, item, taxrule, question):

88
src/tests/api/utils.py Normal file
View File

@@ -0,0 +1,88 @@
#
# 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/>.
#
import urllib.parse
def _add_params(url, params):
url_parts = list(urllib.parse.urlparse(url))
query = urllib.parse.parse_qs(url_parts[4])
query = [*query, *params]
url_parts[4] = urllib.parse.urlencode(query)
return urllib.parse.urlunparse(url_parts)
def _find_field_names(d: dict, path):
names = set()
for k, v in d.items():
if isinstance(v, dict):
names |= _find_field_names(v, path=(*path, k))
elif isinstance(v, list) and len(v) > 0:
names |= _find_field_names(v[0], path=(*path, k))
else:
names.add(".".join([*path, k]))
return names
def _test_configurable_serializer(client, url, field_name_samples, expands):
# Test include
resp = client.get(_add_params(url, [("include", f) for f in field_name_samples]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
found_field_names = _find_field_names(o, tuple())
# Assert no unexpected fields
for f in found_field_names:
depth = f.count(".")
assert f in field_name_samples or any(f.rsplit(".", c)[0] in field_name_samples for c in range(depth))
# Assert all fields are there
for f in field_name_samples:
assert f in found_field_names
# Test exclude
resp = client.get(_add_params(url, [("exclude", f) for f in field_name_samples]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
found_field_names = _find_field_names(o, [])
# Assert all fields are not there
for f in found_field_names:
assert f not in field_name_samples
# Test expand
if expands:
resp = client.get(_add_params(url, [("expand", f) for f in expands]))
if "results" in resp.data:
o = resp.data["results"][0]
else:
o = resp.data
for e in expands:
path = e.split(".")
obj = o
while len(path) > 1:
obj = o[path[0]]
if isinstance(obj, list):
obj = obj[0]
path = path[1:]
assert isinstance(obj[path[0]], dict)