From 3b664f8b76216ba7b683def765fdca3ae4fdf08d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 2 Jan 2025 17:04:32 +0100 Subject: [PATCH] .. --- doc/storefrontapi/fundamentals.rst | 114 ++++++++++++ doc/storefrontapi/index.rst | 17 ++ doc/storefrontapi/reference/index.rst | 7 + src/pretix/base/models/orders.py | 3 - src/pretix/base/storelogic/addons.py | 118 ++++++++++++ src/pretix/presale/checkoutflow.py | 125 ++----------- .../storefrontapi/endpoints/checkout.py | 170 +++++++++++++++++- src/pretix/storefrontapi/endpoints/event.py | 61 +++++-- src/pretix/storefrontapi/middleware.py | 12 +- src/pretix/storefrontapi/steps.py | 42 +++++ 10 files changed, 531 insertions(+), 138 deletions(-) create mode 100644 doc/storefrontapi/fundamentals.rst create mode 100644 doc/storefrontapi/index.rst create mode 100644 doc/storefrontapi/reference/index.rst create mode 100644 src/pretix/base/storelogic/addons.py create mode 100644 src/pretix/storefrontapi/steps.py diff --git a/doc/storefrontapi/fundamentals.rst b/doc/storefrontapi/fundamentals.rst new file mode 100644 index 0000000000..50008b7136 --- /dev/null +++ b/doc/storefrontapi/fundamentals.rst @@ -0,0 +1,114 @@ +Basic concepts +============== + +This page describes basic concepts and definition that you need to know to interact +with our Storefront API, such as authentication, pagination and similar definitions. + +.. _`storefront-auth`: + +Authentication +-------------- + +The storefront API requires authentication with an API key. You receive two kinds of API keys for the storefront API: +Publishable keys and private keys. Publishable keys should be used when your website directly connects to the API. +Private keys should be used only on server-to-server connections. + +Localization +------------ + +The storefront API will return localized and translated strings in many cases if you set an ``Accept-Language`` header. +The selected locale will only be respected if it is active for the organizer or event in question. + +.. _`storefront-compat`: + +Compatibility +------------- + +.. note:: + + The storefront API is currently considered experimental and may change without notice. + Once we declare the API stable, the following compatibility policy will apply. + +We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll +build new features in a way that keeps all pre-existing API usage unchanged. In some cases, +this might not be possible or only possible with restrictions. In these case, any +backwards-incompatible changes will be prominently noted in the "Changes to the REST API" +section of our release notes. If possible, we will announce them multiple releases in advance. + +We treat the following types of changes as *backwards-compatible* so we ask you to make sure +that your clients can deal with them properly: + +* Support of new API endpoints +* Support of new HTTP methods for a given API endpoint +* Support of new query parameters for a given API endpoint +* New fields contained in API responses +* New possible values of enumeration-like fields +* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes) + +We treat the following types of changes as *backwards-incompatible*: + +* Type changes of fields in API responses +* New required input fields for an API endpoint +* New required type for input fields of an API endpoint +* Removal of endpoints, API methods or fields + +Pagination +---------- + +Most lists of objects returned by pretix' API will be paginated. The response will take +the form of: + +.. sourcecode:: javascript + + { + "count": 117, + "next": "https://pretix.eu/api/v1/organizers/?page=2", + "previous": null, + "results": […], + } + +As you can see, the response contains the total number of results in the field ``count``. +The fields ``next`` and ``previous`` contain links to the next and previous page of results, +respectively, or ``null`` if there is no such page. You can use those URLs to retrieve the +respective page. + +The field ``results`` contains a list of objects representing the first results. For most +objects, every page contains 50 results. You can specify a lower pagination size using the +``page_size`` query parameter, but no more than 50. + +Errors +------ + +Error responses (of type 400-499) are returned in one of the following forms, depending on +the type of error. General errors look like: + +.. sourcecode:: http + + HTTP/1.1 405 Method Not Allowed + Content-Type: application/json + Content-Length: 42 + + {"detail": "Method 'DELETE' not allowed."} + +Field specific input errors include the name of the offending fields as keys in the response: + +.. sourcecode:: http + + HTTP/1.1 400 Bad Request + Content-Type: application/json + Content-Length: 94 + + {"amount": ["A valid integer is required."], "description": ["This field may not be blank."]} + +If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`. + +Time Machine +------------ + +Just like our shop frontend, the API allows simulating responses at a different point in time using the +``X-Storefront-Time-Machine-Date`` header. This mechanism only works when the shop is in test mode. + +Data types +---------- + +See :ref:`data types ` of the REST API. diff --git a/doc/storefrontapi/index.rst b/doc/storefrontapi/index.rst new file mode 100644 index 0000000000..cc373d4d68 --- /dev/null +++ b/doc/storefrontapi/index.rst @@ -0,0 +1,17 @@ +.. _`storefront-api`: + +Storefront API +============== + +This part of the documentation contains information about the headless e-commerce +API exposed by pretix that can be used to build a custom checkout experience. + +.. note:: + + The storefront API is currently considered experimental and may change without notice. + +.. toctree:: + :maxdepth: 2 + + fundamentals + reference/index diff --git a/doc/storefrontapi/reference/index.rst b/doc/storefrontapi/reference/index.rst new file mode 100644 index 0000000000..68bd23817c --- /dev/null +++ b/doc/storefrontapi/reference/index.rst @@ -0,0 +1,7 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 2 + + foo \ No newline at end of file diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 21632dc219..fa33104ec8 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -3095,9 +3095,6 @@ class CheckoutSession(models.Model): testmode = models.BooleanField(default=False) session_data = models.JSONField(default=dict) - def cart_positions(self): - return CartPosition.objects.filter(event_id=self.event_id, cart_id=self.cart_id) - class CartPosition(AbstractPosition): """ diff --git a/src/pretix/base/storelogic/addons.py b/src/pretix/base/storelogic/addons.py new file mode 100644 index 0000000000..dfbd154335 --- /dev/null +++ b/src/pretix/base/storelogic/addons.py @@ -0,0 +1,118 @@ +import copy +from collections import defaultdict + +from pretix.base.models.tax import TaxedPrice +from pretix.base.storelogic.products import get_items_for_product_list + + +def addons_is_completed(cart_positions): + for cartpos in cart_positions.filter(addon_to__isnull=True).prefetch_related( + 'item__addons', 'item__addons__addon_category', 'addons', 'addons__item' + ): + a = cartpos.addons.all() + for iao in cartpos.item.addons.all(): + found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled]) + if found < iao.min_count or found > iao.max_count: + return False + return True + + +def addons_is_applicable(cart_positions): + return cart_positions.filter(item__addons__isnull=False).exists() + + +def get_addon_groups(event, sales_channel, customer, cart_positions): + quota_cache = {} + item_cache = {} + groups = [] + for cartpos in sorted(cart_positions.filter(addon_to__isnull=True).prefetch_related( + 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', + ), key=lambda c: c.sort_key): + groupentry = { + 'pos': cartpos, + 'item': cartpos.item, + 'variation': cartpos.variation, + 'categories': [] + } + + current_addon_products = defaultdict(list) + for a in cartpos.addons.all(): + if not a.is_bundled: + current_addon_products[a.item_id, a.variation_id].append(a) + + for iao in cartpos.item.addons.all(): + ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk) + + if ckey not in item_cache: + # Get all items to possibly show + items, _btn = get_items_for_product_list( + event, + subevent=cartpos.subevent, + voucher=None, + channel=sales_channel, + base_qs=iao.addon_category.items, + allow_addons=True, + quota_cache=quota_cache, + memberships=( + customer.usable_memberships( + for_event=cartpos.subevent or event, + testmode=event.testmode + ) + if customer else None + ), + ) + item_cache[ckey] = items + else: + # We can use the cache to prevent a database fetch, but we need separate Python objects + # or our things below like setting `i.initial` will do the wrong thing. + items = [copy.copy(i) for i in item_cache[ckey]] + for i in items: + i.available_variations = [copy.copy(v) for v in i.available_variations] + + for i in items: + i.allow_waitinglist = False + + if i.has_variations: + for v in i.available_variations: + v.initial = len(current_addon_products[i.pk, v.pk]) + if v.initial and i.free_price: + a = current_addon_products[i.pk, v.pk][0] + v.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name if a.item.tax_rule else "", + rate=a.tax_rate, + code=a.item.tax_rule.code if a.item.tax_rule else None, + ) + else: + v.initial_price = v.suggested_price + i.expand = any(v.initial for v in i.available_variations) + else: + i.initial = len(current_addon_products[i.pk, None]) + if i.initial and i.free_price: + a = current_addon_products[i.pk, None][0] + i.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name if a.item.tax_rule else "", + rate=a.tax_rate, + code=a.item.tax_rule.code if a.item.tax_rule else None, + ) + else: + i.initial_price = i.suggested_price + + if items: + groupentry['categories'].append({ + 'category': iao.addon_category, + 'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included), + 'multi_allowed': iao.multi_allowed, + 'min_count': iao.min_count, + 'max_count': iao.max_count, + 'iao': iao, + 'items': items + }) + if groupentry['categories']: + groups.append(groupentry) + return groups diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 849a38c173..b3e47d10bb 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -34,7 +34,6 @@ import copy import inspect import uuid -from collections import defaultdict from decimal import Decimal from django.conf import settings @@ -61,7 +60,7 @@ from pretix.base.models.items import Question from pretix.base.models.orders import ( InvoiceAddress, OrderPayment, QuestionAnswer, ) -from pretix.base.models.tax import TaxedPrice, TaxRule +from pretix.base.models.tax import TaxRule from pretix.base.services.cart import ( CartError, CartManager, add_payment_to_cart, error_messages, get_fees, set_cart_addons, @@ -72,7 +71,9 @@ from pretix.base.services.orders import perform_order from pretix.base.services.tasks import EventTask from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import validate_cart_addons -from pretix.base.storelogic.products import get_items_for_product_list +from pretix.base.storelogic.addons import ( + addons_is_applicable, addons_is_completed, get_addon_groups, +) from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.rich_text import rich_text_snippet @@ -493,7 +494,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): self.request = request # check whether addons are applicable - if get_cart(request).filter(item__addons__isnull=False).exists(): + if addons_is_applicable(get_cart(request)): return True # don't re-check whether cross-selling is applicable if we're already past the AddOnsStep @@ -517,19 +518,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): return request._checkoutflow_addons_applicable def is_completed(self, request, warn=False): - if getattr(self, '_completed', None) is not None: - return self._completed - for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related( - 'item__addons', 'item__addons__addon_category', 'addons', 'addons__item' - ): - a = cartpos.addons.all() - for iao in cartpos.item.addons.all(): - found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled]) - if found < iao.min_count or found > iao.max_count: - self._completed = False - return False - self._completed = True - return True + if getattr(self, '_completed', None) is None: + self._completed = addons_is_completed(get_cart(request)) + return self._completed @cached_property def forms(self): @@ -537,100 +528,12 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): A list of forms with one form for each cart position that can have add-ons. All forms have a custom prefix, so that they can all be submitted at once. """ - formset = [] - quota_cache = {} - item_cache = {} - for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( - 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', - ), key=lambda c: c.sort_key): - formsetentry = { - 'pos': cartpos, - 'item': cartpos.item, - 'variation': cartpos.variation, - 'categories': [] - } - - current_addon_products = defaultdict(list) - for a in cartpos.addons.all(): - if not a.is_bundled: - current_addon_products[a.item_id, a.variation_id].append(a) - - for iao in cartpos.item.addons.all(): - ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk) - - if ckey not in item_cache: - # Get all items to possibly show - items, _btn = get_items_for_product_list( - self.request.event, - subevent=cartpos.subevent, - voucher=None, - channel=self.request.sales_channel, - base_qs=iao.addon_category.items, - allow_addons=True, - quota_cache=quota_cache, - memberships=( - self.request.customer.usable_memberships( - for_event=cartpos.subevent or self.request.event, - testmode=self.request.event.testmode - ) - if getattr(self.request, 'customer', None) else None - ), - ) - item_cache[ckey] = items - else: - # We can use the cache to prevent a database fetch, but we need separate Python objects - # or our things below like setting `i.initial` will do the wrong thing. - items = [copy.copy(i) for i in item_cache[ckey]] - for i in items: - i.available_variations = [copy.copy(v) for v in i.available_variations] - - for i in items: - i.allow_waitinglist = False - - if i.has_variations: - for v in i.available_variations: - v.initial = len(current_addon_products[i.pk, v.pk]) - if v.initial and i.free_price: - a = current_addon_products[i.pk, v.pk][0] - v.initial_price = TaxedPrice( - net=a.price - a.tax_value, - gross=a.price, - tax=a.tax_value, - name=a.item.tax_rule.name if a.item.tax_rule else "", - rate=a.tax_rate, - code=a.item.tax_rule.code if a.item.tax_rule else None, - ) - else: - v.initial_price = v.suggested_price - i.expand = any(v.initial for v in i.available_variations) - else: - i.initial = len(current_addon_products[i.pk, None]) - if i.initial and i.free_price: - a = current_addon_products[i.pk, None][0] - i.initial_price = TaxedPrice( - net=a.price - a.tax_value, - gross=a.price, - tax=a.tax_value, - name=a.item.tax_rule.name if a.item.tax_rule else "", - rate=a.tax_rate, - code=a.item.tax_rule.code if a.item.tax_rule else None, - ) - else: - i.initial_price = i.suggested_price - - if items: - formsetentry['categories'].append({ - 'category': iao.addon_category, - 'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included), - 'multi_allowed': iao.multi_allowed, - 'min_count': iao.min_count, - 'max_count': iao.max_count, - 'iao': iao, - 'items': items - }) - if formsetentry['categories']: - formset.append(formsetentry) - return formset + return get_addon_groups( + self.request.event, + self.request.sales_channel, + getattr(self.request, 'customer', None), + get_cart(self.request), + ) @cached_property def cross_selling_is_applicable(self): diff --git a/src/pretix/storefrontapi/endpoints/checkout.py b/src/pretix/storefrontapi/endpoints/checkout.py index 686c654ef2..5a0a36812c 100644 --- a/src/pretix/storefrontapi/endpoints/checkout.py +++ b/src/pretix/storefrontapi/endpoints/checkout.py @@ -10,13 +10,20 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from rest_framework.reverse import reverse -from pretix.base.models import Item, ItemVariation, SubEvent -from pretix.base.models.orders import CartPosition, CheckoutSession -from pretix.base.services.cart import add_items_to_cart, error_messages +from pretix.base.models import Item, ItemVariation, SubEvent, TaxRule +from pretix.base.models.orders import CartPosition, CheckoutSession, OrderFee +from pretix.base.services.cart import ( + add_items_to_cart, error_messages, get_fees, set_cart_addons, +) +from pretix.base.storelogic.addons import get_addon_groups from pretix.base.timemachine import time_machine_now from pretix.presale.views.cart import generate_cart_id +from pretix.storefrontapi.endpoints.event import ( + CategorySerializer, ItemSerializer, +) from pretix.storefrontapi.permission import StorefrontEventPermission from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer +from pretix.storefrontapi.steps import get_steps logger = logging.getLogger(__name__) @@ -33,6 +40,24 @@ class CartAddLineSerializer(serializers.Serializer): voucher = serializers.CharField(allow_null=True, required=False) +class CartAddonLineSerializer(CartAddLineSerializer): + voucher = None + addon_to = serializers.PrimaryKeyRelatedField( + queryset=CartPosition.objects.none(), required=True + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["addon_to"].queryset = CartPosition.objects.filter( + cart_id=self.context["cart_id"], addon_to__isnull=True + ) + + def to_internal_value(self, data): + i = super().to_internal_value(data) + i["addon_to"] = i["addon_to"].pk + return i + + class InlineItemSerializer(I18nFlattenedModelSerializer): class Meta: @@ -64,6 +89,20 @@ class InlineSubEventSerializer(I18nFlattenedModelSerializer): ] +class CartFeeSerializer(serializers.ModelSerializer): + + class Meta: + model = OrderFee + fields = [ + "fee_type", + "description", + "value", + "tax_rate", + "tax_value", + "internal_type", + ] + + class CartPositionSerializer(serializers.ModelSerializer): # todo: prefetch related items item = InlineItemSerializer(read_only=True) @@ -73,6 +112,8 @@ class CartPositionSerializer(serializers.ModelSerializer): class Meta: model = CartPosition fields = [ + "id", + "addon_to", "item", "variation", "subevent", @@ -84,7 +125,6 @@ class CartPositionSerializer(serializers.ModelSerializer): class CheckoutSessionSerializer(serializers.ModelSerializer): - cart_positions = CartPositionSerializer(many=True) class Meta: model = CheckoutSession @@ -92,9 +132,52 @@ class CheckoutSessionSerializer(serializers.ModelSerializer): "cart_id", "sales_channel", "testmode", - "cart_positions", ] + def to_representation(self, checkout): + d = super().to_representation(checkout) + + cartpos = CartPosition.objects.filter( + event_id=self.context["event"], cart_id=checkout.cart_id + ) + total = sum(p.price for p in cartpos) + + try: + fees = get_fees( + self.context["event"], + self.context["request"], + total, + ( + checkout.invoice_address + if hasattr(checkout, "invoice_address") + else None + ), + payments=[], # todo + positions=cartpos, + ) + except TaxRule.SaleNotAllowed: + # ignore for now, will fail on order creation + fees = [] + + total += sum([f.value for f in fees]) + d["cart_positions"] = CartPositionSerializer( + sorted(cartpos, key=lambda c: c.sort_key), many=True + ).data + d["cart_fees"] = CartFeeSerializer(fees, many=True).data + d["total"] = str(total) + + steps = get_steps(self.context["event"], cartpos) + d["steps"] = {} + for step in steps: + applicable = step.is_applicable() + valid = not applicable or step.is_valid() + d["steps"][step.identifier] = { + "applicable": applicable, + "valid": valid, + } + + return d + class CheckoutViewSet(viewsets.ViewSet): queryset = CheckoutSession.objects.none() @@ -109,6 +192,7 @@ class CheckoutViewSet(viewsets.ViewSet): instance=cs, context={ "event": self.request.event, + "request": self.request, }, ) return Response( @@ -140,6 +224,82 @@ class CheckoutViewSet(viewsets.ViewSet): ) return self._return_checkout_status(cs, status=200) + @action(detail=True, methods=["GET", "PUT"]) + def addons(self, request, *args, **kwargs): + cs = get_object_or_404( + self.request.event.checkout_sessions, cart_id=kwargs["cart_id"] + ) + groups = get_addon_groups( + self.request.event, + self.request.sales_channel, + cs.customer, + CartPosition.objects.filter(cart_id=cs.cart_id), + ) + ctx = { + "event": self.request.event, + } + + if request.method == "PUT": + serializer = CartAddonLineSerializer( + data=request.data.get("lines", []), + many=True, + context={ + "event": self.request.event, + "cart_id": cs.cart_id, + }, + ) + serializer.is_valid(raise_exception=True) + # todo: early validation, validate_cart_addons? + return self._do_async( + cs, + set_cart_addons, + self.request.event.pk, + serializer.validated_data, + [], + cs.cart_id, + locale=translation.get_language(), + invoice_address=( + cs.invoice_address.pk if hasattr(cs, "invoice_address") else None + ), + sales_channel=cs.sales_channel.identifier, + override_now_dt=time_machine_now(default=None), + ) + elif request.method == "GET": + data = [ + { + "parent": CartPositionSerializer(grp["pos"], context=ctx).data, + "categories": [ + { + "category": CategorySerializer( + cat["category"], context=ctx + ).data, + "multi_allowed": cat["multi_allowed"], + "min_count": cat["min_count"], + "max_count": cat["max_count"], + "items": ItemSerializer( + cat["items"], + many=True, + context={ + **ctx, + "price_included": cat["price_included"], + "max_count": ( + cat["max_count"] if cat["multi_allowed"] else 1 + ), + }, + ).data, + } + for cat in grp["categories"] + ], + } + for grp in groups + ] + return Response( + data={ + "groups": data, + }, + status=200, + ) + @action(detail=True, methods=["POST"]) def add_to_cart(self, request, *args, **kwargs): cs = get_object_or_404( diff --git a/src/pretix/storefrontapi/endpoints/event.py b/src/pretix/storefrontapi/endpoints/event.py index 08b970681f..b815ccf7ba 100644 --- a/src/pretix/storefrontapi/endpoints/event.py +++ b/src/pretix/storefrontapi/endpoints/event.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.utils.translation import gettext_lazy as _ from rest_framework import serializers, viewsets from rest_framework.generics import get_object_or_404 @@ -6,6 +8,7 @@ from rest_framework.response import Response from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Quota, SubEvent, ) +from pretix.base.models.tax import TaxedPrice from pretix.base.storelogic.products import ( get_items_for_product_list, item_group_by_category, ) @@ -86,20 +89,32 @@ class PricingField(serializers.Field): return None item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item + suggested_price = item.suggested_price + display_price = item.display_price + + if self.context.get("price_included"): + display_price = TaxedPrice( + gross=Decimal("0.00"), + net=Decimal("0.00"), + tax=Decimal("0.00"), + rate=Decimal("0.00"), + name="", + code=None, + ) + + if hasattr(item, "initial_price"): + # Pre-select current price for add-ons + suggested_price = item.initial_price return { "display_price": { - "net": opt_str(item_or_var.display_price.net), - "gross": opt_str(item_or_var.display_price.gross), + "net": opt_str(display_price.net), + "gross": opt_str(display_price.gross), "tax_rate": opt_str( - item_or_var.display_price.rate - if not item.includes_mixed_tax_rate - else None + display_price.rate if not item.includes_mixed_tax_rate else None ), "tax_name": opt_str( - item_or_var.display_price.name - if not item.includes_mixed_tax_rate - else None + display_price.name if not item.includes_mixed_tax_rate else None ), }, "original_price": ( @@ -122,20 +137,16 @@ class PricingField(serializers.Field): ), "free_price": item.free_price, "suggested_price": { - "net": opt_str(item_or_var.suggested_price.net), - "gross": opt_str(item_or_var.suggested_price.gross), + "net": opt_str(suggested_price.net), + "gross": opt_str(suggested_price.gross), "tax_rate": opt_str( - item_or_var.suggested_price.rate - if not item.includes_mixed_tax_rate - else None + suggested_price.rate if not item.includes_mixed_tax_rate else None ), "tax_name": opt_str( - item_or_var.suggested_price.name - if not item.includes_mixed_tax_rate - else None + suggested_price.name if not item.includes_mixed_tax_rate else None ), }, - "mandatory_priced_addons": item.mandatory_priced_addons, + "mandatory_priced_addons": getattr(item, "mandatory_priced_addons", False), "includes_mixed_tax_rate": item.includes_mixed_tax_rate, } @@ -211,7 +222,7 @@ class AvailabilityField(serializers.Field): "code": "ok", "message": None, "waiting_list": False, - "max_selection": item_or_var.order_max, + "max_selection": self.context.get("max_count", item_or_var.order_max), "quota_left": ( item_or_var.cached_availability[1] if item.show_quota_left @@ -236,6 +247,13 @@ class VariationSerializer(I18nFlattenedModelSerializer): "availability", ] + def to_representation(self, instance): + r = super().to_representation(instance) + if hasattr(instance, "initial"): + # Used for addons + r["initial_count"] = instance.initial + return r + class ItemSerializer(I18nFlattenedModelSerializer): description = RichTextField() @@ -258,6 +276,13 @@ class ItemSerializer(I18nFlattenedModelSerializer): "availability", ] + def to_representation(self, instance): + r = super().to_representation(instance) + if hasattr(instance, "initial"): + # Used for addons + r["initial_count"] = instance.initial + return r + class ProductGroupField(serializers.Field): def to_representation(self, ev): diff --git a/src/pretix/storefrontapi/middleware.py b/src/pretix/storefrontapi/middleware.py index 35a68cdbf2..c6a8cf9b4e 100644 --- a/src/pretix/storefrontapi/middleware.py +++ b/src/pretix/storefrontapi/middleware.py @@ -129,7 +129,17 @@ class ApiMiddleware: LocaleMiddleware(NotImplementedError).process_request(request) r = self.get_response(request) r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist? - r["Access-Control-Allow-Headers"] = ",".join( + r["Access-Control-Allow-Methods"] = ", ".join( + [ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "DELETE", + ] + ) + r["Access-Control-Allow-Headers"] = ", ".join( [ "Content-Type", "X-Storefront-Time-Machine-Date", diff --git a/src/pretix/storefrontapi/steps.py b/src/pretix/storefrontapi/steps.py new file mode 100644 index 0000000000..dfa679b350 --- /dev/null +++ b/src/pretix/storefrontapi/steps.py @@ -0,0 +1,42 @@ +from pretix.base.storelogic.addons import ( + addons_is_applicable, addons_is_completed, +) + + +class CheckoutStep: + def __init__(self, event, cart_positions): + self.event = event + self.cart_positions = cart_positions + + @property + def identifier(self): + raise NotImplementedError() + + def is_applicable(self): + raise NotImplementedError() + + def is_valid(self): + raise NotImplementedError() + + +class AddonStep(CheckoutStep): + identifier = "addons" + + def is_applicable(self): + return addons_is_applicable(self.cart_positions) + + def is_valid(self): + return addons_is_completed(self.cart_positions) + + +def get_steps(event, cart_positions): + return [ + AddonStep(event, cart_positions), + # todo: cross-selling + # todo: customers + # todo: memberships + # todo: questions + # todo: plugin signals + # todo: payment + # todo: confirmations + ]