diff --git a/src/pretix/base/migrations/0275_checkoutsession_invoiceaddress_checkout_session.py b/src/pretix/base/migrations/0275_checkoutsession_invoiceaddress_checkout_session.py new file mode 100644 index 000000000..1bd498803 --- /dev/null +++ b/src/pretix/base/migrations/0275_checkoutsession_invoiceaddress_checkout_session.py @@ -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", + ), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index dd8a35bfe..21632dc21 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -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): """ diff --git a/src/pretix/base/services/cleanup.py b/src/pretix/base/services/cleanup.py index 9190b0ab2..5cf2bb499 100644 --- a/src/pretix/base/services/cleanup.py +++ b/src/pretix/base/services/cleanup.py @@ -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() diff --git a/src/pretix/storefrontapi/endpoints/checkout.py b/src/pretix/storefrontapi/endpoints/checkout.py new file mode 100644 index 000000000..686c654ef --- /dev/null +++ b/src/pretix/storefrontapi/endpoints/checkout.py @@ -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[^/]+)", + ) + 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, + ) diff --git a/src/pretix/storefrontapi/endpoints/event.py b/src/pretix/storefrontapi/endpoints/event.py index 05324df64..08b970681 100644 --- a/src/pretix/storefrontapi/endpoints/event.py +++ b/src/pretix/storefrontapi/endpoints/event.py @@ -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, diff --git a/src/pretix/storefrontapi/middleware.py b/src/pretix/storefrontapi/middleware.py index 2de595ce1..35a68cdbf 100644 --- a/src/pretix/storefrontapi/middleware.py +++ b/src/pretix/storefrontapi/middleware.py @@ -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) diff --git a/src/pretix/storefrontapi/urls.py b/src/pretix/storefrontapi/urls.py index 7f38d687d..34f19aff7 100644 --- a/src/pretix/storefrontapi/urls.py +++ b/src/pretix/storefrontapi/urls.py @@ -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():