This commit is contained in:
Raphael Michel
2025-01-02 17:04:32 +01:00
parent 92eb5e3ece
commit 3b664f8b76
10 changed files with 531 additions and 138 deletions

View File

@@ -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 <rest-types>` of the REST API.

View File

@@ -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

View File

@@ -0,0 +1,7 @@
API Reference
=============
.. toctree::
:maxdepth: 2
foo

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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):

View File

@@ -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(

View File

@@ -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):

View File

@@ -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",

View File

@@ -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
]