Allow sale of blocked seats on specific channels (#1518)

* Allow sale of blocked seats on specific channels

* Add docs
This commit is contained in:
Raphael Michel
2019-12-11 15:56:20 +01:00
committed by GitHub
parent 6d3ccc0182
commit 352942b7d6
10 changed files with 87 additions and 19 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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):

View File

@@ -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())

View File

@@ -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()

View File

@@ -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:

View File

@@ -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'), (

View File

@@ -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(

View File

@@ -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