From 5139eeb03bfbb4b558aa0763ad2cd3dfb065b3bb Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 24 Jun 2025 15:01:56 +0200 Subject: [PATCH] more work --- doc/api/resources/events.rst | 4 + doc/api/resources/items.rst | 10 +- doc/api/resources/orders.rst | 8 +- doc/api/resources/organizers.rst | 2 + doc/api/resources/subevents.rst | 117 ++++++++++++------------ src/pretix/api/serializers/__init__.py | 20 +++- src/pretix/api/serializers/event.py | 16 ++-- src/pretix/api/serializers/item.py | 79 +++++++++------- src/pretix/api/serializers/order.py | 27 ++++++ src/pretix/api/serializers/organizer.py | 4 +- src/pretix/api/views/item.py | 1 + src/tests/api/test_events.py | 10 ++ src/tests/api/test_items.py | 23 ++++- src/tests/api/test_orders.py | 5 +- src/tests/api/test_organizers.py | 10 ++ src/tests/api/test_subevents.py | 10 ++ src/tests/api/utils.py | 13 +-- 17 files changed, 243 insertions(+), 116 deletions(-) diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 5064377b6a..188dc07cbd 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -152,6 +152,8 @@ Endpoints and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response slow. :query search: Only return events matching a given search query. + :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. :param organizer: The ``slug`` field of a valid organizer :statuscode 200: no error :statuscode 401: Authentication failure @@ -223,6 +225,8 @@ Endpoints :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch + :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 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 383da02d7d..459b6d5f0d 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -19,7 +19,7 @@ name multi-lingual string The item's vi internal_name string An optional name that is only used in the backend default_price money (string) The item price that is applied if the price is not overwritten by variations or other options. -category integer The ID of the category this item belongs to +category integer (expandable) The ID of the category this item belongs to (or ``null``). active boolean If ``false``, the item is hidden from all public lists and will not be sold. @@ -33,7 +33,7 @@ free_price_suggestion money (string) A suggested p ``free_price`` is set (or ``null``). tax_rate decimal (string) The VAT rate to be applied for this item (read-only, set through ``tax_rule``). -tax_rule integer The internal ID of the applied tax rule (or ``null``). +tax_rule integer (expandable) The internal ID of the applied tax rule (or ``null``). admission boolean ``true`` for items that grant admission to the event (such as primary tickets) and ``false`` for others (such as add-ons or merchandise). @@ -390,6 +390,9 @@ Endpoints will be returned. :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. Default: ``position`` + :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. + :query string expand: Expand an object reference with the referenced object. Can be passed multiple times. :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch :statuscode 200: no error @@ -531,6 +534,9 @@ Endpoints :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch :param id: The ``id`` field of the item to fetch + :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. + :query string expand: Expand an object reference with the referenced object. Can be passed multiple times. :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 23ea2954a8..4d496e7151 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -157,8 +157,8 @@ order string Order code of t positionid integer Number of the position within the order canceled boolean Whether or not this position has been canceled. Note that by default, only non-canceled positions are shown. -item integer ID of the purchased item -variation integer ID of the purchased variation (or ``null``) +item integer (expandable) ID of the purchased item +variation integer (expandable) ID of the purchased variation (or ``null``) price money (string) Price of this position attendee_name string Specified attendee name for this position (or ``null``) attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name) @@ -170,7 +170,7 @@ city string Attendee city ( country string Attendee country code (or ``null``) state string Attendee state (ISO 3166-2 code). Only supported in AU, BR, CA, CN, MY, MX, and US, otherwise ``null``. -voucher integer Internal ID of the voucher used for this position (or ``null``) +voucher integer (expandable) Internal ID of the voucher used for this position (or ``null``) voucher_budget_use money (string) Amount of money discounted by the voucher, corresponding to how much of the ``budget`` of the voucher is consumed. **Important:** Do not rely on this amount to be a useful @@ -182,7 +182,7 @@ tax_code string Codified reason tax_rule integer The ID of the used tax rule (or ``null``) secret string Secret code printed on the tickets for validation addon_to integer Internal ID of the position this position is an add-on for (or ``null``) -subevent integer ID of the date inside an event series this position belongs to (or ``null``). +subevent integer (expandable) ID of the date inside an event series this position belongs to (or ``null``). discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``). blocked list of strings A list of strings, or ``null``. Whenever not ``null``, the ticket may not be used (e.g. for check-in). valid_from datetime The ticket will not be valid before this time. Can be ``null``. diff --git a/doc/api/resources/organizers.rst b/doc/api/resources/organizers.rst index 2c61f2a678..5af68e7caf 100644 --- a/doc/api/resources/organizers.rst +++ b/doc/api/resources/organizers.rst @@ -93,6 +93,8 @@ Endpoints } :param organizer: The ``slug`` field of the organizer to fetch + :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 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index 0a3f77a193..23850f4536 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -146,10 +146,70 @@ Endpoints attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved", and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response slow. + :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 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/ + + Returns information on one sub-event, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": {"en": "First Sample Conference"}, + "event": "sampleconf", + "active": false, + "is_public": true, + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "presale_start": null, + "presale_end": null, + "location": null, + "geo_lat": null, + "geo_lon": null, + "seating_plan": null, + "seat_category_mapping": {}, + "item_price_overrides": [ + { + "item": 2, + "disabled": false, + "available_from": null, + "available_until": null, + "price": "12.00" + } + ], + "variation_price_overrides": [], + "meta_data": {} + } + + :param organizer: The ``slug`` field of a valid organizer + :param event: The ``slug`` field of the main event + :param id: The ``id`` field of the sub-event to fetch + :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 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/ Creates a new subevent. @@ -237,63 +297,6 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. - -.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/ - - Returns information on one sub-event, identified by its ID. - - **Example request**: - - .. sourcecode:: http - - GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 - Host: pretix.eu - Accept: application/json, text/javascript - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "id": 1, - "name": {"en": "First Sample Conference"}, - "event": "sampleconf", - "active": false, - "is_public": true, - "date_from": "2017-12-27T10:00:00Z", - "date_to": null, - "date_admission": null, - "presale_start": null, - "presale_end": null, - "location": null, - "geo_lat": null, - "geo_lon": null, - "seating_plan": null, - "seat_category_mapping": {}, - "item_price_overrides": [ - { - "item": 2, - "disabled": false, - "available_from": null, - "available_until": null, - "price": "12.00" - } - ], - "variation_price_overrides": [], - "meta_data": {} - } - - :param organizer: The ``slug`` field of a valid organizer - :param event: The ``slug`` field of the main event - :param id: The ``id`` field of the sub-event to fetch - :statuscode 200: no error - :statuscode 401: Authentication failure - :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. - .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/ Updates a sub-event, identified by its ID. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py index 4d4590545f..d8137b3aa1 100644 --- a/src/pretix/api/serializers/__init__.py +++ b/src/pretix/api/serializers/__init__.py @@ -132,7 +132,7 @@ class SalesChannelMigrationMixin: s.identifier for s in self.organizer.sales_channels.all() ]) - else: + elif "limit_sales_channels" in value: value["sales_channels"] = value["limit_sales_channels"] return value @@ -145,6 +145,9 @@ class ConfigurableSerializerMixin: # Do not support include requests when the serializer is used for writing # TODO: think about this return set() + if getattr(self, "parent", None): + # Field selection is always handled by top-level serializer + return set() if 'exclude' in self.context: return self.context['exclude'] elif 'request' in self.context: @@ -156,6 +159,9 @@ class ConfigurableSerializerMixin: # Do not support include requests when the serializer is used for writing # TODO: think about this return set() + if getattr(self, "parent", None): + # Field selection is always handled by top-level serializer + return set() if 'include' in self.context: return self.context['include'] elif 'request' in self.context: @@ -167,6 +173,9 @@ class ConfigurableSerializerMixin: # Do not support expand requests when the serializer is used for writing # TODO: think about this return set() + if getattr(self, "parent", None): + # Field selection is always handled by top-level serializer + return set() if 'expand' in self.context: return self.context['expand'] elif 'request' in self.context: @@ -231,6 +240,13 @@ class ConfigurableSerializerMixin: if not perm_holder.has_event_permission(request.organizer, request.event, ef["permission"], request=request): raise PermissionDenied(f"No permission to expand field {field}") + if hasattr(self, "instance") and "prefetch" in ef: + for prefetch in ef["prefetch"]: + prefetch_related_objects( + self.instance if hasattr(self.instance, '__iter__') else [self.instance], + prefetch + ) + return ef["serializer"]( read_only=True, context=self.context, @@ -241,7 +257,7 @@ class ConfigurableSerializerMixin: expanded = False for expand in sorted(list(self.get_expand_requests())): - expanded = expanded or self._expand_field(self, expand.split('.'), expand) + expanded = self._expand_field(self, expand.split('.'), expand) or expanded includes = set(self.get_include_requests()) if includes: diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 6161221793..e28e000a9a 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -48,7 +48,8 @@ from rest_framework.fields import ChoiceField, Field from rest_framework.relations import SlugRelatedField from pretix.api.serializers import ( - CompatibleJSONField, SalesChannelMigrationMixin, + CompatibleJSONField, ConfigurableSerializerMixin, + SalesChannelMigrationMixin, ) from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.settings import SettingsSerializer @@ -167,7 +168,7 @@ class ValidKeysField(Field): } -class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): +class EventSerializer(SalesChannelMigrationMixin, ConfigurableSerializerMixin, I18nAwareModelSerializer): meta_data = MetaDataField(required=False, source='*') item_meta_properties = MetaPropertyField(required=False, source='*') plugins = PluginsField(required=False, source='*') @@ -198,10 +199,11 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not hasattr(self.context['request'], 'event'): - self.fields.pop('valid_keys') + self.fields.pop('valid_keys', None) if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: - self.fields.pop('best_availability_state') - self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all() + self.fields.pop('best_availability_state', None) + if 'limit_sales_channels' in self.fields: + self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all() def validate(self, data): data = super().validate(data) @@ -483,7 +485,7 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer): fields = ('variation', 'price', 'disabled', 'available_from', 'available_until') -class SubEventSerializer(I18nAwareModelSerializer): +class SubEventSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer): item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False) variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False) seat_category_mapping = SeatCategoryMappingField(source='*', required=False) @@ -502,7 +504,7 @@ class SubEventSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: - self.fields.pop('best_availability_state') + self.fields.pop('best_availability_state', None) def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 21b7d07ec1..1da5ff6667 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -42,8 +42,10 @@ from django.utils.functional import cached_property, lazy from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from pretix.api.serializers import SalesChannelMigrationMixin -from pretix.api.serializers.event import MetaDataField +from pretix.api.serializers import ( + ConfigurableSerializerMixin, SalesChannelMigrationMixin, +) +from pretix.api.serializers.event import MetaDataField, TaxRuleSerializer from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( @@ -246,7 +248,29 @@ class ItemTaxRateField(serializers.Field): return str(Decimal('0.00')) -class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): +class ItemCategorySerializer(I18nAwareModelSerializer): + + class Meta: + model = ItemCategory + fields = ( + 'id', 'name', 'internal_name', 'description', 'position', + 'is_addon', 'cross_selling_mode', + 'cross_selling_condition', 'cross_selling_match_products' + ) + + def validate(self, data): + data = super().validate(data) + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + if full_data.get('is_addon') and full_data.get('cross_selling_mode'): + raise ValidationError('is_addon and cross_selling_mode are mutually exclusive') + + return data + + +class ItemSerializer(SalesChannelMigrationMixin, ConfigurableSerializerMixin, I18nAwareModelSerializer): addons = InlineItemAddOnSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False) @@ -262,6 +286,16 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): allow_empty=True, many=True, ) + expand_fields = { + "category": { + "serializer": ItemCategorySerializer, + "prefetch": ["category"], + }, + "tax_rule": { + "serializer": TaxRuleSerializer, + "prefetch": ["tax_rule"], + }, + } class Meta: model = Item @@ -284,13 +318,18 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['default_price'].allow_null = False - self.fields['default_price'].required = True + if 'default_price' in self.fields: + self.fields['default_price'].allow_null = False + self.fields['default_price'].required = True if not self.read_only: - self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() - self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all() - self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() - self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() + if 'require_membership_types' in self.fields: + self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() + if 'grant_membership_type' in self.fields: + self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all() + if 'limit_sales_channels' in self.fields: + self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() + if 'variations' in self.fields and 'limit_sales_channels' in self.fields['variations'].child.fields: + self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() def validate(self, data): data = super().validate(data) @@ -437,28 +476,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): return item -class ItemCategorySerializer(I18nAwareModelSerializer): - - class Meta: - model = ItemCategory - fields = ( - 'id', 'name', 'internal_name', 'description', 'position', - 'is_addon', 'cross_selling_mode', - 'cross_selling_condition', 'cross_selling_match_products' - ) - - def validate(self, data): - data = super().validate(data) - - full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} - full_data.update(data) - - if full_data.get('is_addon') and full_data.get('cross_selling_mode'): - raise ValidationError('is_addon and cross_selling_mode are mutually exclusive') - - return data - - class QuestionOptionSerializer(I18nAwareModelSerializer): identifier = serializers.CharField(allow_null=True) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index d9815e2a4d..5565f5b6e0 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -782,6 +782,33 @@ class OrderSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer): "positions.voucher": { "serializer": VoucherSerializer, "permission": "can_view_vouchers", + "prefetch": ["positions__voucher"], + }, + "positions.item": { + "serializer": ItemSerializer, + "prefetch": [ + "positions__item", + "positions__item__addons", + "positions__item__bundles", + "positions__item__meta_values", + "positions__item__variations", + "positions__item__tax_rule", + ], + }, + "positions.variation": { + "serializer": ItemSerializer, + "prefetch": ["positions__variation", "positions__variation__meta_values"], + }, + "positions.subevent": { + "serializer": SubEventSerializer, + "prefetch": [ + "positions__subevent", + "positions__subevent__event", + "positions__subevent__subeventitem_set", + "positions__subevent__subeventitemvariation_set", + "positions__subevent__seat_category_mappings", + "positions__subevent__meta_values", + ], }, } diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 47594cd310..65ea0da2ab 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -31,7 +31,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from pretix.api.auth.devicesecurity import get_all_security_profiles -from pretix.api.serializers import AsymmetricField, ConfigurableSerializer +from pretix.api.serializers import AsymmetricField, ConfigurableSerializerMixin from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.settings import SettingsSerializer @@ -51,7 +51,7 @@ from pretix.multidomain.urlreverse import build_absolute_uri logger = logging.getLogger(__name__) -class OrganizerSerializer(ConfigurableSerializer, I18nAwareModelSerializer): +class OrganizerSerializer(ConfigurableSerializerMixin, I18nAwareModelSerializer): public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True) def get_organizer_url(self, organizer): diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 83e0dc19be..500767b7d2 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -121,6 +121,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): def get_serializer_context(self): ctx = super().get_serializer_context() ctx['event'] = self.request.event + ctx['request'] = self.request return ctx def perform_update(self, serializer): diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 34d5c70cc9..0c8284cfe0 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -44,6 +44,7 @@ from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scope, scopes_disabled from tests import assert_num_queries +from tests.api.utils import _test_configurable_serializer from tests.const import SAMPLE_PNG from pretix.base.models import ( @@ -215,6 +216,15 @@ def test_event_list_filter(token_client, organizer, event): assert resp.status_code == 200 assert resp.data['count'] == 0 + _test_configurable_serializer( + token_client, + "/api/v1/organizers/{}/events/".format(organizer.slug), + [ + "slug", "live", "meta_data", "seating_plan", "item_meta_properties" + ], + expands=[] + ) + @pytest.mark.django_db def test_event_list_name_filter(token_client, organizer, event): diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 4da007e4a7..ab5b2e43d4 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -42,6 +42,7 @@ from django.conf import settings from django.core.files.base import ContentFile from django_countries.fields import Country from django_scopes import scopes_disabled +from tests.api.utils import _test_configurable_serializer from tests.const import SAMPLE_PNG from pretix.base.models import ( @@ -359,10 +360,17 @@ TEST_ITEM_RES = { @pytest.mark.django_db -def test_item_list(token_client, organizer, event, team, item): +def test_item_list(token_client, organizer, event, team, item, taxrule): cat = event.categories.create(name="foo") + cat2 = event.categories.create(name="bar") + item.category = cat2 + item.tax_rule = taxrule + item.save() res = dict(TEST_ITEM_RES) res["id"] = item.pk + res["category"] = cat2.pk + res["tax_rule"] = taxrule.pk + res["tax_rate"] = "19.00" resp = token_client.get('/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug)) assert resp.status_code == 200 assert [res] == resp.data['results'] @@ -400,11 +408,11 @@ def test_item_list(token_client, organizer, event, team, item): assert resp.status_code == 200 assert [] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=0'.format(organizer.slug, event.slug)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=19'.format(organizer.slug, event.slug)) assert resp.status_code == 200 assert [res] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=19'.format(organizer.slug, event.slug)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=0'.format(organizer.slug, event.slug)) assert resp.status_code == 200 assert [] == resp.data['results'] @@ -419,6 +427,15 @@ def test_item_list(token_client, organizer, event, team, item): assert resp.status_code == 200 assert [] == resp.data['results'] + _test_configurable_serializer( + token_client, + "/api/v1/organizers/{}/events/{}/items/".format(organizer.slug, event.slug), + [ + "name", "free_price", "variations", + ], + expands=["category", "tax_rule"], + ) + @pytest.mark.django_db def test_item_detail(token_client, organizer, event, team, item): diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index dd06b6aac0..c37d75c4fb 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -533,8 +533,8 @@ def test_order_list(token_client, organizer, event, order, item, team, taxrule, 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" + assert resp.status_code == 403 + assert resp.data["detail"] == "No permission to expand field positions.voucher" @pytest.mark.django_db @@ -543,6 +543,7 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques with scopes_disabled(): res["positions"][0]["id"] = order.positions.first().pk res["positions"][0]["print_logs"][0]["id"] = order.positions.first().print_logs.first().pk + res["positions"][0]["print_logs"][0]["device_id"] = order.positions.first().print_logs.first().device_id res["fees"][0]["id"] = order.fees.first().pk res["positions"][0]["item"] = item.pk res["fees"][0]["tax_rule"] = taxrule.pk diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py index b5bb0c208b..c222961bd7 100644 --- a/src/tests/api/test_organizers.py +++ b/src/tests/api/test_organizers.py @@ -21,6 +21,7 @@ # import pytest from django.core.files.base import ContentFile +from tests.api.utils import _test_configurable_serializer from tests.const import SAMPLE_PNG TEST_ORGANIZER_RES = { @@ -36,6 +37,15 @@ def test_organizer_list(token_client, organizer): assert resp.status_code == 200 assert TEST_ORGANIZER_RES in resp.data['results'] + _test_configurable_serializer( + token_client, + "/api/v1/organizers/", + [ + "name", "public_url" + ], + expands=[], + ) + @pytest.mark.django_db def test_organizer_detail(token_client, organizer): diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index 4bc26bc506..113702e1e3 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -26,6 +26,7 @@ from unittest import mock import pytest from django_countries.fields import Country from django_scopes import scopes_disabled +from tests.api.utils import _test_configurable_serializer from pretix.base.models import ( InvoiceAddress, ItemVariation, Order, OrderPosition, SeatingPlan, SubEvent, @@ -157,6 +158,15 @@ def test_subevent_list(token_client, organizer, event, subevent): assert resp.status_code == 200 assert resp.data['results'][0]['best_availability_state'] is None + _test_configurable_serializer( + token_client, + "/api/v1/organizers/{}/events/{}/subevents/".format(organizer.slug, event.slug), + [ + "name", "active", "item_price_overrides", + ], + expands=[] + ) + @pytest.mark.django_db def test_subevent_list_filter(token_client, organizer, event, subevent): diff --git a/src/tests/api/utils.py b/src/tests/api/utils.py index 264ae85667..9b6b3753a6 100644 --- a/src/tests/api/utils.py +++ b/src/tests/api/utils.py @@ -33,12 +33,11 @@ def _add_params(url, params): def _find_field_names(d: dict, path): names = set() for k, v in d.items(): + names.add(".".join([*path, k])) if isinstance(v, dict): names |= _find_field_names(v, path=(*path, k)) - elif isinstance(v, list) and len(v) > 0: + elif isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict): names |= _find_field_names(v[0], path=(*path, k)) - else: - names.add(".".join([*path, k])) return names @@ -54,10 +53,12 @@ def _test_configurable_serializer(client, url, field_name_samples, expands): # 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 (f in field_name_samples or + any(f.rsplit(".", c)[0] in field_name_samples for c in range(depth + 1)) or + any(fn.startswith(f + ".") for fn in field_name_samples)) # Assert all fields are there for f in field_name_samples: - assert f in found_field_names + assert f in found_field_names, f"{f} not in {found_field_names}" # Test exclude resp = client.get(_add_params(url, [("exclude", f) for f in field_name_samples])) @@ -85,4 +86,4 @@ def _test_configurable_serializer(client, url, field_name_samples, expands): if isinstance(obj, list): obj = obj[0] path = path[1:] - assert isinstance(obj[path[0]], dict) + assert isinstance(obj[path[0]], dict), f"{e} is not a dictionary, but {type(obj[path[0]])}"