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 = models.ForeignKey(
Event, Event,
verbose_name=_("Event"), verbose_name=_("Event"),
on_delete=models.CASCADE related_name="checkout_sessions",
on_delete=models.CASCADE,
) )
cart_id = models.CharField( cart_id = models.CharField(
max_length=255, unique=True, max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)") verbose_name=_("Cart ID (e.g. session key)"),
) )
created = models.DateTimeField( created = models.DateTimeField(
verbose_name=_("Date"), verbose_name=_("Date"),
auto_now_add=True auto_now_add=True,
) )
customer = models.ForeignKey( customer = models.ForeignKey(
Customer, Customer,
@@ -3094,6 +3095,9 @@ 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):
""" """

View File

@@ -23,6 +23,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled 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 import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task from ..signals import periodic_task
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
cp.delete() cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True): for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete() 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)): for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete() 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) return str(o)
class RichtTextField(serializers.Field): class RichTextField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
return rich_text(value) return rich_text(value)
@@ -69,7 +69,7 @@ class EventSettingsField(serializers.Field):
class CategorySerializer(I18nFlattenedModelSerializer): class CategorySerializer(I18nFlattenedModelSerializer):
description = RichtTextField() description = RichTextField()
class Meta: class Meta:
model = ItemCategory model = ItemCategory
@@ -222,7 +222,7 @@ class AvailabilityField(serializers.Field):
class VariationSerializer(I18nFlattenedModelSerializer): class VariationSerializer(I18nFlattenedModelSerializer):
description = RichtTextField() description = RichTextField()
pricing = PricingField(source="*") pricing = PricingField(source="*")
availability = AvailabilityField(source="*") availability = AvailabilityField(source="*")
@@ -238,7 +238,7 @@ class VariationSerializer(I18nFlattenedModelSerializer):
class ItemSerializer(I18nFlattenedModelSerializer): class ItemSerializer(I18nFlattenedModelSerializer):
description = RichtTextField() description = RichTextField()
available_variations = VariationSerializer(many=True, read_only=True) available_variations = VariationSerializer(many=True, read_only=True)
pricing = PricingField(source="*") pricing = PricingField(source="*")
availability = AvailabilityField(source="*") availability = AvailabilityField(source="*")
@@ -253,7 +253,6 @@ class ItemSerializer(I18nFlattenedModelSerializer):
"description", "description",
"picture", "picture",
"min_per_order", "min_per_order",
"free_price",
"available_variations", "available_variations",
"pricing", "pricing",
"availability", "availability",
@@ -269,6 +268,7 @@ class ProductGroupField(serializers.Field):
subevent=ev if isinstance(ev, SubEvent) else None, subevent=ev if isinstance(ev, SubEvent) else None,
require_seat=False, require_seat=False,
channel=self.context["sales_channel"], channel=self.context["sales_channel"],
voucher=None, # TODO
memberships=( memberships=(
self.context["customer"].usable_memberships( self.context["customer"].usable_memberships(
for_event=ev, testmode=event.testmode for_event=ev, testmode=event.testmode
@@ -321,8 +321,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
# todo: vouchers_exist # todo: vouchers_exist
# todo: date range # todo: date range
# todo: waiting list info # todo: seating, seating waiting list
# todo: has seating
class Meta: class Meta:
model = SubEvent model = SubEvent
@@ -346,8 +345,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
class EventDetailSerializer(BaseEventDetailSerializer): class EventDetailSerializer(BaseEventDetailSerializer):
# todo: vouchers_exist # todo: vouchers_exist
# todo: date range # todo: date range
# todo: waiting list info # todo: seating, seating waiting list
# todo: has seating
product_list = ProductGroupField(source="*") product_list = ProductGroupField(source="*")
class Meta: class Meta:
@@ -379,6 +377,7 @@ class EventViewSet(viewsets.ViewSet):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
event = request.event # Lookup is already done event = request.event # Lookup is already done
# todo: prefetch related items
ctx = { ctx = {
"sales_channel": request.sales_channel, "sales_channel": request.sales_channel,

View File

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

View File

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