This commit is contained in:
Raphael Michel
2025-01-01 23:24:26 +01:00
parent ad38a7a407
commit 92eb5e3ece
7 changed files with 339 additions and 13 deletions

View File

@@ -0,0 +1,62 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0274_tax_codes"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]

View File

@@ -3071,15 +3071,16 @@ class CheckoutSession(models.Model):
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
on_delete=models.CASCADE
related_name="checkout_sessions",
on_delete=models.CASCADE,
)
cart_id = models.CharField(
max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)")
verbose_name=_("Cart ID (e.g. session key)"),
)
created = models.DateTimeField(
verbose_name=_("Date"),
auto_now_add=True
auto_now_add=True,
)
customer = models.ForeignKey(
Customer,
@@ -3094,6 +3095,9 @@ class CheckoutSession(models.Model):
testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict)
def cart_positions(self):
return CartPosition.objects.filter(event_id=self.event_id, cart_id=self.cart_id)
class CartPosition(AbstractPosition):
"""

View File

@@ -23,6 +23,7 @@ from datetime import timedelta
from django.conf import settings
from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
@@ -32,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
):
cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()

View File

@@ -0,0 +1,246 @@
import logging
from celery.result import AsyncResult
from django.core.exceptions import ValidationError
from django.utils import translation
from django.utils.translation import gettext as _
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
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.timemachine import time_machine_now
from pretix.presale.views.cart import generate_cart_id
from pretix.storefrontapi.permission import StorefrontEventPermission
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
logger = logging.getLogger(__name__)
class CartAddLineSerializer(serializers.Serializer):
item = serializers.IntegerField()
variation = serializers.IntegerField(allow_null=True, required=False)
subevent = serializers.IntegerField(allow_null=True, required=False)
count = serializers.IntegerField(default=1)
seat = serializers.CharField(allow_null=True, required=False)
price = serializers.DecimalField(
allow_null=True, required=False, decimal_places=2, max_digits=13
)
voucher = serializers.CharField(allow_null=True, required=False)
class InlineItemSerializer(I18nFlattenedModelSerializer):
class Meta:
model = Item
fields = [
"id",
"name",
]
class InlineItemVariationSerializer(I18nFlattenedModelSerializer):
class Meta:
model = ItemVariation
fields = [
"id",
"value",
]
class InlineSubEventSerializer(I18nFlattenedModelSerializer):
class Meta:
model = SubEvent
fields = [
"id",
"name",
"date_from",
]
class CartPositionSerializer(serializers.ModelSerializer):
# todo: prefetch related items
item = InlineItemSerializer(read_only=True)
variation = InlineItemVariationSerializer(read_only=True)
subevent = InlineSubEventSerializer(read_only=True)
class Meta:
model = CartPosition
fields = [
"item",
"variation",
"subevent",
"price",
"expires",
# todo: attendee_name, attendee_email, voucher, addon_to, used_membership, seat, is_bundled, discount
# todo: address, requested_valid_from
]
class CheckoutSessionSerializer(serializers.ModelSerializer):
cart_positions = CartPositionSerializer(many=True)
class Meta:
model = CheckoutSession
fields = [
"cart_id",
"sales_channel",
"testmode",
"cart_positions",
]
class CheckoutViewSet(viewsets.ViewSet):
queryset = CheckoutSession.objects.none()
lookup_url_kwarg = "cart_id"
lookup_field = "cart_id"
permission_classes = [
StorefrontEventPermission,
]
def _return_checkout_status(self, cs: CheckoutSession, status=200):
serializer = CheckoutSessionSerializer(
instance=cs,
context={
"event": self.request.event,
},
)
return Response(
serializer.data,
status=status,
)
def create(self, request, *args, **kwargs):
if (
request.event.presale_start
and time_machine_now() < request.event.presale_start
):
raise ValidationError(error_messages["not_started"])
if request.event.presale_has_ended:
raise ValidationError(error_messages["ended"])
cs = CheckoutSession.objects.create(
event=request.event,
cart_id=generate_cart_id(),
sales_channel=request.sales_channel,
testmode=request.event.testmode,
session_data={},
)
return self._return_checkout_status(cs, status=201)
def retrieve(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
return self._return_checkout_status(cs, status=200)
@action(detail=True, methods=["POST"])
def add_to_cart(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
serializer = CartAddLineSerializer(
data=request.data.get("lines", []),
many=True,
context={
"event": self.request.event,
},
)
serializer.is_valid(raise_exception=True)
return self._do_async(
cs,
add_items_to_cart,
self.request.event.pk,
serializer.validated_data,
cs.cart_id,
translation.get_language(),
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
{},
cs.sales_channel.identifier,
time_machine_now(default=None),
)
@action(
detail=True,
methods=["GET"],
url_name="task_status",
url_path="task/(?P<asyncid>[^/]+)",
)
def task_status(self, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
res = AsyncResult(kwargs["asyncid"])
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self._async_success(res, cs)
else:
return self._async_error(res, cs)
return self._async_pending(res, cs)
def _do_async(self, cs, task, *args, **kwargs):
try:
res = task.apply_async(args=args, kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = task.apply_async(args=args, kwargs=kwargs)
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self._async_success(res, cs)
else:
return self._async_error(res, cs)
return self._async_pending(res, cs)
def _async_success(self, res, cs):
return Response(
{
"status": "ok",
"checkout_session": self._return_checkout_status(cs).data,
},
status=status.HTTP_200_OK,
)
def _async_error(self, res, cs):
if isinstance(res.info, dict) and res.info["exc_type"] in [
"OrderError",
"CartError",
]:
message = res.info["exc_message"]
elif res.info.__class__.__name__ in ["OrderError", "CartError"]:
message = str(res.info)
else:
logger.error("Unexpected exception: %r" % res.info)
message = _("An unexpected error has occurred, please try again later.")
return Response(
{
"status": "error",
"message": message,
},
status=status.HTTP_409_CONFLICT, # todo: find better status code
)
def _async_pending(self, res, cs):
return Response(
{
"status": "pending",
"check_url": reverse(
"storefrontapi-v1:checkoutsession-task_status",
kwargs={
"organizer": self.request.organizer.slug,
"event": self.request.event.slug,
"cart_id": cs.cart_id,
"asyncid": res.id,
},
request=self.request,
),
},
status=status.HTTP_202_ACCEPTED,
)

View File

@@ -21,7 +21,7 @@ def opt_str(o):
return str(o)
class RichtTextField(serializers.Field):
class RichTextField(serializers.Field):
def to_representation(self, value):
return rich_text(value)
@@ -69,7 +69,7 @@ class EventSettingsField(serializers.Field):
class CategorySerializer(I18nFlattenedModelSerializer):
description = RichtTextField()
description = RichTextField()
class Meta:
model = ItemCategory
@@ -222,7 +222,7 @@ class AvailabilityField(serializers.Field):
class VariationSerializer(I18nFlattenedModelSerializer):
description = RichtTextField()
description = RichTextField()
pricing = PricingField(source="*")
availability = AvailabilityField(source="*")
@@ -238,7 +238,7 @@ class VariationSerializer(I18nFlattenedModelSerializer):
class ItemSerializer(I18nFlattenedModelSerializer):
description = RichtTextField()
description = RichTextField()
available_variations = VariationSerializer(many=True, read_only=True)
pricing = PricingField(source="*")
availability = AvailabilityField(source="*")
@@ -253,7 +253,6 @@ class ItemSerializer(I18nFlattenedModelSerializer):
"description",
"picture",
"min_per_order",
"free_price",
"available_variations",
"pricing",
"availability",
@@ -269,6 +268,7 @@ class ProductGroupField(serializers.Field):
subevent=ev if isinstance(ev, SubEvent) else None,
require_seat=False,
channel=self.context["sales_channel"],
voucher=None, # TODO
memberships=(
self.context["customer"].usable_memberships(
for_event=ev, testmode=event.testmode
@@ -321,8 +321,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
# todo: vouchers_exist
# todo: date range
# todo: waiting list info
# todo: has seating
# todo: seating, seating waiting list
class Meta:
model = SubEvent
@@ -346,8 +345,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
class EventDetailSerializer(BaseEventDetailSerializer):
# todo: vouchers_exist
# todo: date range
# todo: waiting list info
# todo: has seating
# todo: seating, seating waiting list
product_list = ProductGroupField(source="*")
class Meta:
@@ -379,6 +377,7 @@ class EventViewSet(viewsets.ViewSet):
def retrieve(self, request, *args, **kwargs):
event = request.event # Lookup is already done
# todo: prefetch related items
ctx = {
"sales_channel": request.sales_channel,

View File

@@ -129,6 +129,14 @@ 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(
[
"Content-Type",
"X-Storefront-Time-Machine-Date",
"Accept",
"Accept-Language",
]
)
return r
finally:
timemachine_now_var.set(None)

View File

@@ -25,12 +25,13 @@ from django.apps import apps
from django.urls import include, re_path
from rest_framework import routers
from .endpoints import event
from .endpoints import checkout, event
storefront_orga_router = routers.DefaultRouter()
storefront_orga_router.register(r"events", event.EventViewSet)
storefront_event_router = routers.DefaultRouter()
storefront_event_router.register(r"checkouts", checkout.CheckoutViewSet)
# Force import of all plugins to give them a chance to register URLs with the router
for app in apps.get_app_configs():