forked from CGM_Public/pretix_original
cart add
This commit is contained in:
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
246
src/pretix/storefrontapi/endpoints/checkout.py
Normal file
246
src/pretix/storefrontapi/endpoints/checkout.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user