mirror of
https://github.com/pretix/pretix.git
synced 2026-02-05 02:32:28 +00:00
..
This commit is contained in:
114
doc/storefrontapi/fundamentals.rst
Normal file
114
doc/storefrontapi/fundamentals.rst
Normal 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
doc/storefrontapi/index.rst
Normal file
17
doc/storefrontapi/index.rst
Normal 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
doc/storefrontapi/reference/index.rst
Normal file
7
doc/storefrontapi/reference/index.rst
Normal file
@@ -0,0 +1,7 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
foo
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
118
src/pretix/base/storelogic/addons.py
Normal file
118
src/pretix/base/storelogic/addons.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
src/pretix/storefrontapi/steps.py
Normal file
42
src/pretix/storefrontapi/steps.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user