mirror of
https://github.com/pretix/pretix.git
synced 2026-08-20 12:26:27 +00:00
..
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
foo
|
||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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 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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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