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
+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.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(
+43 -18
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets
from rest_framework.generics import get_object_or_404
@@ -6,6 +8,7 @@ from rest_framework.response import Response
from pretix.base.models import (
Event, Item, ItemCategory, ItemVariation, Quota, SubEvent,
)
from pretix.base.models.tax import TaxedPrice
from pretix.base.storelogic.products import (
get_items_for_product_list, item_group_by_category,
)
@@ -86,20 +89,32 @@ class PricingField(serializers.Field):
return None
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item
suggested_price = item.suggested_price
display_price = item.display_price
if self.context.get("price_included"):
display_price = TaxedPrice(
gross=Decimal("0.00"),
net=Decimal("0.00"),
tax=Decimal("0.00"),
rate=Decimal("0.00"),
name="",
code=None,
)
if hasattr(item, "initial_price"):
# Pre-select current price for add-ons
suggested_price = item.initial_price
return {
"display_price": {
"net": opt_str(item_or_var.display_price.net),
"gross": opt_str(item_or_var.display_price.gross),
"net": opt_str(display_price.net),
"gross": opt_str(display_price.gross),
"tax_rate": opt_str(
item_or_var.display_price.rate
if not item.includes_mixed_tax_rate
else None
display_price.rate if not item.includes_mixed_tax_rate else None
),
"tax_name": opt_str(
item_or_var.display_price.name
if not item.includes_mixed_tax_rate
else None
display_price.name if not item.includes_mixed_tax_rate else None
),
},
"original_price": (
@@ -122,20 +137,16 @@ class PricingField(serializers.Field):
),
"free_price": item.free_price,
"suggested_price": {
"net": opt_str(item_or_var.suggested_price.net),
"gross": opt_str(item_or_var.suggested_price.gross),
"net": opt_str(suggested_price.net),
"gross": opt_str(suggested_price.gross),
"tax_rate": opt_str(
item_or_var.suggested_price.rate
if not item.includes_mixed_tax_rate
else None
suggested_price.rate if not item.includes_mixed_tax_rate else None
),
"tax_name": opt_str(
item_or_var.suggested_price.name
if not item.includes_mixed_tax_rate
else None
suggested_price.name if not item.includes_mixed_tax_rate else None
),
},
"mandatory_priced_addons": item.mandatory_priced_addons,
"mandatory_priced_addons": getattr(item, "mandatory_priced_addons", False),
"includes_mixed_tax_rate": item.includes_mixed_tax_rate,
}
@@ -211,7 +222,7 @@ class AvailabilityField(serializers.Field):
"code": "ok",
"message": None,
"waiting_list": False,
"max_selection": item_or_var.order_max,
"max_selection": self.context.get("max_count", item_or_var.order_max),
"quota_left": (
item_or_var.cached_availability[1]
if item.show_quota_left
@@ -236,6 +247,13 @@ class VariationSerializer(I18nFlattenedModelSerializer):
"availability",
]
def to_representation(self, instance):
r = super().to_representation(instance)
if hasattr(instance, "initial"):
# Used for addons
r["initial_count"] = instance.initial
return r
class ItemSerializer(I18nFlattenedModelSerializer):
description = RichTextField()
@@ -258,6 +276,13 @@ class ItemSerializer(I18nFlattenedModelSerializer):
"availability",
]
def to_representation(self, instance):
r = super().to_representation(instance)
if hasattr(instance, "initial"):
# Used for addons
r["initial_count"] = instance.initial
return r
class ProductGroupField(serializers.Field):
def to_representation(self, ev):
+11 -1
View File
@@ -129,7 +129,17 @@ class ApiMiddleware:
LocaleMiddleware(NotImplementedError).process_request(request)
r = self.get_response(request)
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist?
r["Access-Control-Allow-Headers"] = ",".join(
r["Access-Control-Allow-Methods"] = ", ".join(
[
"GET",
"POST",
"HEAD",
"OPTIONS",
"PUT",
"DELETE",
]
)
r["Access-Control-Allow-Headers"] = ", ".join(
[
"Content-Type",
"X-Storefront-Time-Machine-Date",
+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
]