forked from CGM_Public/pretix_original
Allow sale of blocked seats on specific channels (#1518)
* Allow sale of blocked seats on specific channels * Add docs
This commit is contained in:
@@ -30,11 +30,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel')
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
@@ -86,11 +87,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available():
|
||||
if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')):
|
||||
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
|
||||
@@ -778,7 +778,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
|
||||
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
|
||||
@@ -385,7 +385,7 @@ class Event(EventMixin, LoggedModel):
|
||||
if img:
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None):
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
@@ -397,7 +397,7 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
return self.seats.annotate(
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event=self,
|
||||
@@ -415,7 +415,10 @@ class Event(EventMixin, LoggedModel):
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False, blocked=False)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
@@ -1006,7 +1009,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.name, self.get_date_range_display())
|
||||
|
||||
def free_seats(self, ignore_voucher=None):
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
@@ -1019,7 +1022,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
return self.seats.annotate(
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event_id=self.event_id,
|
||||
@@ -1039,7 +1042,10 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, blocked=False, has_voucher=False)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@cached_property
|
||||
def settings(self):
|
||||
|
||||
@@ -135,10 +135,10 @@ class Seat(models.Model):
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None):
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'):
|
||||
from .orders import Order
|
||||
|
||||
if self.blocked:
|
||||
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
||||
return False
|
||||
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
cpqs = self.cartposition_set.filter(expires__gte=now())
|
||||
|
||||
@@ -271,7 +271,7 @@ class CartManager:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
seated = self._is_seated(op.item, op.subevent)
|
||||
if seated and (not op.seat or op.seat.blocked):
|
||||
if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)):
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
elif op.seat and not seated:
|
||||
raise CartError(error_messages['seat_forbidden'])
|
||||
@@ -830,7 +830,7 @@ class CartManager:
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel):
|
||||
available_count = 0
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
@@ -878,7 +878,7 @@ class CartManager:
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if op.seat and not op.seat.is_available(ignore_cart=op.position,
|
||||
if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel,
|
||||
ignore_voucher_id=op.position.voucher_id):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
op.position.addons.all().delete()
|
||||
|
||||
@@ -418,7 +418,8 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
raise OrderError(error_messages['ended'])
|
||||
|
||||
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||
sales_channel='web'):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
@@ -512,7 +513,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
if cp.seat:
|
||||
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
|
||||
# time, since we absolutely can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id) or cp.seat.blocked:
|
||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
@@ -806,7 +807,7 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr)
|
||||
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel)
|
||||
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
gift_cards=gift_cards, shown_total=shown_total)
|
||||
@@ -1197,7 +1198,7 @@ class OrderChangeManager:
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
continue
|
||||
if not seat.is_available() or diff > 1:
|
||||
if not seat.is_available(sales_channel=self.order.sales_channel) or diff > 1:
|
||||
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
|
||||
|
||||
if self.event.has_subevents:
|
||||
|
||||
@@ -754,7 +754,11 @@ Your {event} team"""))
|
||||
'giftcard_length': {
|
||||
'default': settings.ENTROPY['giftcard_secret'],
|
||||
'type': int
|
||||
}
|
||||
},
|
||||
'seating_allow_blocked_seats_for_channel': {
|
||||
'default': [],
|
||||
'type': list
|
||||
},
|
||||
}
|
||||
PERSON_NAME_TITLE_GROUPS = OrderedDict([
|
||||
('english_common', (_('Most common English titles'), (
|
||||
|
||||
@@ -4,12 +4,15 @@ from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
from pytz import UTC
|
||||
|
||||
from pretix.base.channels import SalesChannel
|
||||
from pretix.base.models import Question, SeatingPlan
|
||||
from pretix.base.models.orders import CartPosition
|
||||
from pretix.base.signals import register_sales_channels
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -49,6 +52,21 @@ def quota(event, item):
|
||||
return q
|
||||
|
||||
|
||||
class FoobarSalesChannel(SalesChannel):
|
||||
identifier = "bar"
|
||||
verbose_name = "Foobar"
|
||||
icon = "home"
|
||||
testmode_supported = False
|
||||
unlimited_items_per_order = True
|
||||
|
||||
|
||||
@receiver(register_sales_channels, dispatch_uid="test_cart_register_sales_channels")
|
||||
def base_sales_channels(sender, **kwargs):
|
||||
return (
|
||||
FoobarSalesChannel(),
|
||||
)
|
||||
|
||||
|
||||
TEST_CARTPOSITION_RES = {
|
||||
'id': 1,
|
||||
'cart_id': 'aaa@api',
|
||||
@@ -163,6 +181,7 @@ CARTPOS_CREATE_PAYLOAD = {
|
||||
'subevent': None,
|
||||
'expires': '2018-06-11T10:00:00Z',
|
||||
'includes_tax': True,
|
||||
'sales_channel': 'web',
|
||||
'answers': []
|
||||
}
|
||||
|
||||
@@ -667,6 +686,23 @@ def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item,
|
||||
assert resp.data == ['The selected seat "A1" is not available.']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cartpos_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question):
|
||||
seat.blocked = True
|
||||
seat.save()
|
||||
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
|
||||
res['item'] = item.pk
|
||||
res['seat'] = seat.seat_guid
|
||||
res['sales_channel'] = 'bar'
|
||||
event.settings.seating_allow_blocked_seats_for_channel = ['bar']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cartpos_create_with_used_seat(token_client, organizer, event, item, quota, seat, question):
|
||||
CartPosition.objects.create(
|
||||
|
||||
@@ -2741,6 +2741,24 @@ def test_order_create_with_seat(token_client, organizer, event, item, quota, sea
|
||||
assert p.seat == seat
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question):
|
||||
seat.blocked = True
|
||||
seat.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['seat'] = seat.seat_guid
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['sales_channel'] = 'bar'
|
||||
event.settings.seating_allow_blocked_seats_for_channel = ['bar']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question):
|
||||
seat.blocked = True
|
||||
|
||||
Reference in New Issue
Block a user