mirror of
https://github.com/pretix/pretix.git
synced 2026-01-07 21:52:26 +00:00
258 lines
11 KiB
Python
258 lines
11 KiB
Python
#
|
|
# This file is part of pretix (Community Edition).
|
|
#
|
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
#
|
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
# this file, see <https://pretix.eu/about/en/license>.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
# <https://www.gnu.org/licenses/>.
|
|
#
|
|
from collections import Counter
|
|
from typing import List
|
|
|
|
from django.db import transaction
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.functional import cached_property
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import gettext as _
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.exceptions import ValidationError
|
|
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
|
from rest_framework.response import Response
|
|
from rest_framework.serializers import as_serializer_error
|
|
|
|
from pretix.api.pagination import TotalOrderingFilter
|
|
from pretix.api.serializers.cart import (
|
|
CartPositionCreateSerializer, CartPositionSerializer,
|
|
)
|
|
from pretix.base.models import CartPosition
|
|
from pretix.base.services.cart import (
|
|
_get_quota_availability, _get_voucher_availability, error_messages,
|
|
)
|
|
from pretix.base.services.locking import lock_objects
|
|
|
|
|
|
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
|
serializer_class = CartPositionSerializer
|
|
queryset = CartPosition.objects.none()
|
|
filter_backends = (TotalOrderingFilter,)
|
|
ordering = ('datetime',)
|
|
ordering_fields = ('datetime', 'cart_id')
|
|
lookup_field = 'id'
|
|
permission = 'event.orders:read'
|
|
write_permission = 'event.orders:write'
|
|
|
|
def get_queryset(self):
|
|
return CartPosition.objects.filter(
|
|
event=self.request.event,
|
|
cart_id__endswith="@api"
|
|
).select_related('seat').prefetch_related('answers')
|
|
|
|
def get_serializer_context(self):
|
|
ctx = super().get_serializer_context()
|
|
ctx['event'] = self.request.event
|
|
ctx['quotas_for_item_cache'] = {}
|
|
ctx['quotas_for_variation_cache'] = {}
|
|
return ctx
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
ctx = self.get_serializer_context()
|
|
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
|
|
serializer.is_valid(raise_exception=True)
|
|
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
|
|
headers = self.get_success_headers(serializer.data)
|
|
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
|
|
|
|
@action(detail=False, methods=['POST'])
|
|
def bulk_create(self, request, *args, **kwargs):
|
|
if not isinstance(request.data, list): # noqa
|
|
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
ctx = self.get_serializer_context()
|
|
serializers = [
|
|
CartPositionCreateSerializer(data=d, context=ctx)
|
|
for d in request.data
|
|
]
|
|
|
|
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
|
|
return Response({'results': results}, status=status.HTTP_200_OK)
|
|
|
|
def perform_create(self, serializer):
|
|
raise NotImplementedError()
|
|
|
|
@transaction.atomic()
|
|
def perform_destroy(self, instance):
|
|
instance.addons.all().delete()
|
|
instance.delete()
|
|
|
|
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
|
|
if voucher_use_diff or seat_diff:
|
|
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often
|
|
return True
|
|
|
|
if quota_diff and any(q.size is not None for q in quota_diff):
|
|
# If any quotas are affected that are not unlimited, we lock
|
|
return True
|
|
|
|
return False
|
|
|
|
@cached_property
|
|
def _create_default_cart_id(self):
|
|
cid = "{}@api".format(get_random_string(48))
|
|
while CartPosition.objects.filter(cart_id=cid).exists():
|
|
cid = "{}@api".format(get_random_string(48))
|
|
return cid
|
|
|
|
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
|
|
voucher_use_diff = Counter()
|
|
quota_diff = Counter()
|
|
seat_diff = Counter()
|
|
results = [{} for pserializer in serializers]
|
|
|
|
for i, pserializer in enumerate(serializers):
|
|
if not pserializer.is_valid(raise_exception=raise_exception):
|
|
results[i] = {
|
|
'success': False,
|
|
'data': None,
|
|
'errors': pserializer.errors,
|
|
}
|
|
|
|
for pserializer in serializers:
|
|
if pserializer.errors:
|
|
continue
|
|
|
|
validated_data = pserializer.validated_data
|
|
if not validated_data.get('cart_id'):
|
|
validated_data['cart_id'] = self._create_default_cart_id
|
|
|
|
if validated_data.get('voucher'):
|
|
voucher_use_diff[validated_data['voucher']] += 1
|
|
|
|
if validated_data.get('seat'):
|
|
seat_diff[validated_data['seat']] += 1
|
|
|
|
for q in validated_data['_quotas']:
|
|
quota_diff[q] += 1
|
|
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
|
for q in sub_data['_quotas']:
|
|
quota_diff[q] += 1
|
|
|
|
seats_seen = set()
|
|
now_dt = now()
|
|
with transaction.atomic():
|
|
full_lock_required = seat_diff and self.request.event.settings.seating_minimal_distance > 0
|
|
if full_lock_required:
|
|
# We lock the entire event in this case since we don't want to deal with fine-granular locking
|
|
# in the case of seating distance enforcement
|
|
lock_objects([self.request.event])
|
|
else:
|
|
lock_objects(
|
|
[q for q, d in quota_diff.items() if q.size is not None and d > 0] +
|
|
[v for v, d in voucher_use_diff.items() if d > 0] +
|
|
[s for s, d in seat_diff.items() if d > 0],
|
|
shared_lock_objects=[self.request.event]
|
|
)
|
|
|
|
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
|
|
self.request.event,
|
|
voucher_use_diff,
|
|
now_dt,
|
|
exclude_position_ids=[],
|
|
)
|
|
quotas_ok = _get_quota_availability(quota_diff, now_dt)
|
|
|
|
for i, pserializer in enumerate(serializers):
|
|
if results[i]:
|
|
continue
|
|
|
|
try:
|
|
validated_data = pserializer.validated_data
|
|
|
|
if validated_data.get('seat'):
|
|
# Assumption: Add-ons currently can't have seats
|
|
if validated_data['seat'] in seats_seen:
|
|
raise ValidationError(error_messages['seat_multiple'])
|
|
seats_seen.add(validated_data['seat'])
|
|
|
|
quotas_needed = Counter()
|
|
for q in validated_data['_quotas']:
|
|
quotas_needed[q] += 1
|
|
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
|
for q in sub_data['_quotas']:
|
|
quotas_needed[q] += 1
|
|
|
|
for q, needed in quotas_needed.items():
|
|
if quotas_ok[q] < needed:
|
|
raise ValidationError(
|
|
_('There is not enough quota available on quota "{}" to perform the operation.').format(
|
|
q.name
|
|
)
|
|
)
|
|
|
|
if validated_data.get('voucher'):
|
|
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
|
|
if vouchers_ok[validated_data['voucher']] < 1:
|
|
raise ValidationError(
|
|
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
|
|
)
|
|
|
|
if validated_data.get('seat'):
|
|
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
|
|
if validated_data.get('sales_channel'):
|
|
sales_channel_id = validated_data.get('sales_channel').identifier
|
|
else:
|
|
sales_channel_id = "web"
|
|
if not validated_data['seat'].is_available(
|
|
sales_channel=sales_channel_id,
|
|
distance_ignore_cart_id=validated_data['cart_id'],
|
|
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
|
):
|
|
raise ValidationError(
|
|
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
|
|
)
|
|
|
|
for q, needed in quotas_needed.items():
|
|
quotas_ok[q] -= needed
|
|
if validated_data.get('voucher'):
|
|
vouchers_ok[validated_data['voucher']] -= 1
|
|
|
|
if any(qa < 0 for qa in quotas_ok.values()):
|
|
# Safeguard, should never happen because of conditions above
|
|
raise ValidationError(error_messages['unavailable'])
|
|
|
|
cp = pserializer.create(validated_data)
|
|
|
|
d = CartPositionSerializer(cp, context=ctx).data
|
|
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
|
|
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
|
|
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
|
|
|
|
results[i] = {
|
|
'success': True,
|
|
'data': d,
|
|
'errors': None,
|
|
}
|
|
except ValidationError as e:
|
|
if raise_exception:
|
|
raise
|
|
results[i] = {
|
|
'success': False,
|
|
'data': None,
|
|
'errors': as_serializer_error(e),
|
|
}
|
|
|
|
return results
|