This commit is contained in:
Raphael Michel
2025-01-04 17:18:37 +01:00
parent 92eb5e3ece
commit 3b664f8b76
10 changed files with 531 additions and 138 deletions
+114
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.
+17
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
+7
View File
@@ -0,0 +1,7 @@
API Reference
=============
.. toctree::
:maxdepth: 2
foo
-3
View File
@@ -3095,9 +3095,6 @@ class CheckoutSession(models.Model):
testmode = models.BooleanField(default=False) testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict) 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): class CartPosition(AbstractPosition):
""" """
+118
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
+14 -111
View File
@@ -34,7 +34,6 @@
import copy import copy
import inspect import inspect
import uuid import uuid
from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
@@ -61,7 +60,7 @@ from pretix.base.models.items import Question
from pretix.base.models.orders import ( from pretix.base.models.orders import (
InvoiceAddress, OrderPayment, QuestionAnswer, InvoiceAddress, OrderPayment, QuestionAnswer,
) )
from pretix.base.models.tax import TaxedPrice, TaxRule from pretix.base.models.tax import TaxRule
from pretix.base.services.cart import ( from pretix.base.services.cart import (
CartError, CartManager, add_payment_to_cart, error_messages, get_fees, CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
set_cart_addons, 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.services.tasks import EventTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import validate_cart_addons 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.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.base.templatetags.rich_text import rich_text_snippet from pretix.base.templatetags.rich_text import rich_text_snippet
@@ -493,7 +494,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
self.request = request self.request = request
# check whether addons are applicable # check whether addons are applicable
if get_cart(request).filter(item__addons__isnull=False).exists(): if addons_is_applicable(get_cart(request)):
return True return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep # 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 return request._checkoutflow_addons_applicable
def is_completed(self, request, warn=False): def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None: if getattr(self, '_completed', None) is None:
return self._completed self._completed = addons_is_completed(get_cart(request))
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related( return self._completed
'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
@cached_property @cached_property
def forms(self): 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. 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. All forms have a custom prefix, so that they can all be submitted at once.
""" """
formset = [] return get_addon_groups(
quota_cache = {} self.request.event,
item_cache = {} self.request.sales_channel,
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( getattr(self.request, 'customer', None),
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', get_cart(self.request),
), 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
@cached_property @cached_property
def cross_selling_is_applicable(self): def cross_selling_is_applicable(self):
+165 -5
View File
@@ -10,13 +10,20 @@ from rest_framework.generics import get_object_or_404
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from pretix.base.models import Item, ItemVariation, SubEvent from pretix.base.models import Item, ItemVariation, SubEvent, TaxRule
from pretix.base.models.orders import CartPosition, CheckoutSession from pretix.base.models.orders import CartPosition, CheckoutSession, OrderFee
from pretix.base.services.cart import add_items_to_cart, error_messages 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.base.timemachine import time_machine_now
from pretix.presale.views.cart import generate_cart_id 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.permission import StorefrontEventPermission
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
from pretix.storefrontapi.steps import get_steps
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,6 +40,24 @@ class CartAddLineSerializer(serializers.Serializer):
voucher = serializers.CharField(allow_null=True, required=False) 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 InlineItemSerializer(I18nFlattenedModelSerializer):
class Meta: 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): class CartPositionSerializer(serializers.ModelSerializer):
# todo: prefetch related items # todo: prefetch related items
item = InlineItemSerializer(read_only=True) item = InlineItemSerializer(read_only=True)
@@ -73,6 +112,8 @@ class CartPositionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = CartPosition model = CartPosition
fields = [ fields = [
"id",
"addon_to",
"item", "item",
"variation", "variation",
"subevent", "subevent",
@@ -84,7 +125,6 @@ class CartPositionSerializer(serializers.ModelSerializer):
class CheckoutSessionSerializer(serializers.ModelSerializer): class CheckoutSessionSerializer(serializers.ModelSerializer):
cart_positions = CartPositionSerializer(many=True)
class Meta: class Meta:
model = CheckoutSession model = CheckoutSession
@@ -92,9 +132,52 @@ class CheckoutSessionSerializer(serializers.ModelSerializer):
"cart_id", "cart_id",
"sales_channel", "sales_channel",
"testmode", "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): class CheckoutViewSet(viewsets.ViewSet):
queryset = CheckoutSession.objects.none() queryset = CheckoutSession.objects.none()
@@ -109,6 +192,7 @@ class CheckoutViewSet(viewsets.ViewSet):
instance=cs, instance=cs,
context={ context={
"event": self.request.event, "event": self.request.event,
"request": self.request,
}, },
) )
return Response( return Response(
@@ -140,6 +224,82 @@ class CheckoutViewSet(viewsets.ViewSet):
) )
return self._return_checkout_status(cs, status=200) 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"]) @action(detail=True, methods=["POST"])
def add_to_cart(self, request, *args, **kwargs): def add_to_cart(self, request, *args, **kwargs):
cs = get_object_or_404( cs = get_object_or_404(
+43 -18
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets from rest_framework import serializers, viewsets
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
@@ -6,6 +8,7 @@ from rest_framework.response import Response
from pretix.base.models import ( from pretix.base.models import (
Event, Item, ItemCategory, ItemVariation, Quota, SubEvent, Event, Item, ItemCategory, ItemVariation, Quota, SubEvent,
) )
from pretix.base.models.tax import TaxedPrice
from pretix.base.storelogic.products import ( from pretix.base.storelogic.products import (
get_items_for_product_list, item_group_by_category, get_items_for_product_list, item_group_by_category,
) )
@@ -86,20 +89,32 @@ class PricingField(serializers.Field):
return None return None
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item 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 { return {
"display_price": { "display_price": {
"net": opt_str(item_or_var.display_price.net), "net": opt_str(display_price.net),
"gross": opt_str(item_or_var.display_price.gross), "gross": opt_str(display_price.gross),
"tax_rate": opt_str( "tax_rate": opt_str(
item_or_var.display_price.rate display_price.rate if not item.includes_mixed_tax_rate else None
if not item.includes_mixed_tax_rate
else None
), ),
"tax_name": opt_str( "tax_name": opt_str(
item_or_var.display_price.name display_price.name if not item.includes_mixed_tax_rate else None
if not item.includes_mixed_tax_rate
else None
), ),
}, },
"original_price": ( "original_price": (
@@ -122,20 +137,16 @@ class PricingField(serializers.Field):
), ),
"free_price": item.free_price, "free_price": item.free_price,
"suggested_price": { "suggested_price": {
"net": opt_str(item_or_var.suggested_price.net), "net": opt_str(suggested_price.net),
"gross": opt_str(item_or_var.suggested_price.gross), "gross": opt_str(suggested_price.gross),
"tax_rate": opt_str( "tax_rate": opt_str(
item_or_var.suggested_price.rate suggested_price.rate if not item.includes_mixed_tax_rate else None
if not item.includes_mixed_tax_rate
else None
), ),
"tax_name": opt_str( "tax_name": opt_str(
item_or_var.suggested_price.name suggested_price.name if not item.includes_mixed_tax_rate else None
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, "includes_mixed_tax_rate": item.includes_mixed_tax_rate,
} }
@@ -211,7 +222,7 @@ class AvailabilityField(serializers.Field):
"code": "ok", "code": "ok",
"message": None, "message": None,
"waiting_list": False, "waiting_list": False,
"max_selection": item_or_var.order_max, "max_selection": self.context.get("max_count", item_or_var.order_max),
"quota_left": ( "quota_left": (
item_or_var.cached_availability[1] item_or_var.cached_availability[1]
if item.show_quota_left if item.show_quota_left
@@ -236,6 +247,13 @@ class VariationSerializer(I18nFlattenedModelSerializer):
"availability", "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): class ItemSerializer(I18nFlattenedModelSerializer):
description = RichTextField() description = RichTextField()
@@ -258,6 +276,13 @@ class ItemSerializer(I18nFlattenedModelSerializer):
"availability", "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): class ProductGroupField(serializers.Field):
def to_representation(self, ev): def to_representation(self, ev):
+11 -1
View File
@@ -129,7 +129,17 @@ class ApiMiddleware:
LocaleMiddleware(NotImplementedError).process_request(request) LocaleMiddleware(NotImplementedError).process_request(request)
r = self.get_response(request) r = self.get_response(request)
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist? 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", "Content-Type",
"X-Storefront-Time-Machine-Date", "X-Storefront-Time-Machine-Date",
+42
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
]