diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index d1b15e876..f3d6de70b 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -38,6 +38,7 @@ quota integer An ID of a quot attached either to a specific product or to all products within one quota or it can be available for all items without restriction. +seat string ``seat_guid`` attribute of a specific seat (or ``null``) tag string A string that is used for grouping vouchers comment string An internal comment on the voucher subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). @@ -53,6 +54,10 @@ show_hidden_items boolean Only if set to The attribute ``show_hidden_items`` has been added. +.. versionchanged:: 3.4 + + The attribute ``seat`` has been added. + Endpoints --------- @@ -96,7 +101,8 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", - "subevent": null + "seat": null, + "subevent": null, } ] } @@ -162,6 +168,7 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", + "seat": null, "subevent": null } @@ -225,6 +232,7 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", + "seat": null, "subevent": null } @@ -352,6 +360,7 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", + "seat": null, "subevent": null } diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index f3c493b66..6f140e5a5 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -2,15 +2,23 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from pretix.api.serializers.i18n import I18nAwareModelSerializer -from pretix.base.models import Voucher +from pretix.base.models import Seat, Voucher class VoucherListSerializer(serializers.ListSerializer): def create(self, validated_data): codes = set() + seats = set() errs = [] err = False for voucher_data in validated_data: + if voucher_data.get('seat') and (voucher_data.get('seat'), voucher_data.get('subevent')) in seats: + err = True + errs.append({'code': ['Duplicate seat ID in request.']}) + continue + else: + seats.add((voucher_data.get('seat'), voucher_data.get('subevent'))) + if voucher_data['code'] in codes: err = True errs.append({'code': ['Duplicate voucher code in request.']}) @@ -22,12 +30,19 @@ class VoucherListSerializer(serializers.ListSerializer): return super().create(validated_data) +class SeatGuidField(serializers.CharField): + def to_representation(self, val: Seat): + return val.seat_guid + + class VoucherSerializer(I18nAwareModelSerializer): + seat = SeatGuidField(allow_null=True, required=False) + class Meta: model = Voucher fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', - 'tag', 'comment', 'subevent', 'show_hidden_items') + 'tag', 'comment', 'subevent', 'show_hidden_items', 'seat') read_only_fields = ('id', 'redeemed') list_serializer_class = VoucherListSerializer @@ -61,4 +76,10 @@ class VoucherSerializer(I18nAwareModelSerializer): ) Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None) + if full_data.get('seat'): + data['seat'] = Voucher.clean_seat_id( + full_data, full_data.get('item'), self.context.get('event'), + self.instance.pk if self.instance else None + ) + return data diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index d897cc226..34ac45a57 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -45,7 +45,7 @@ class VoucherViewSet(viewsets.ModelViewSet): write_permission = 'can_change_vouchers' def get_queryset(self): - return self.request.event.vouchers.all() + return self.request.event.vouchers.select_related('seat').all() def _predict_quota_check(self, data, instance): # This method predicts if Voucher.clean_quota_needs_checking diff --git a/src/pretix/base/migrations/0140_voucher_seat.py b/src/pretix/base/migrations/0140_voucher_seat.py new file mode 100644 index 000000000..6b357e289 --- /dev/null +++ b/src/pretix/base/migrations/0140_voucher_seat.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-11-14 11:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0139_auto_20191019_1317'), + ] + + operations = [ + migrations.AddField( + model_name='voucher', + name='seat', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', to='pretixbase.Seat'), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index b91418387..50bb13a77 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -12,7 +12,7 @@ from django.core.files.storage import default_storage from django.core.mail import get_connection from django.core.validators import RegexValidator from django.db import models -from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.template.defaultfilters import date as _date from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -377,9 +377,18 @@ class Event(EventMixin, LoggedModel): if img: return urljoin(build_absolute_uri(self, 'presale:event.index'), img) - @property - def free_seats(self): + def free_seats(self, ignore_voucher=None): from .orders import CartPosition, Order, OrderPosition + from .vouchers import Voucher + vqs = Voucher.objects.filter( + event=self, + seat_id=OuterRef('pk'), + redeemed__lt=F('max_usages'), + ).filter( + Q(valid_until__isnull=True) | Q(valid_until__gte=now()) + ) + if ignore_voucher: + vqs = vqs.exclude(pk=ignore_voucher.pk) return self.seats.annotate( has_order=Exists( OrderPosition.objects.filter( @@ -394,8 +403,11 @@ class Event(EventMixin, LoggedModel): seat_id=OuterRef('pk'), expires__gte=now() ) + ), + has_voucher=Exists( + vqs ) - ).filter(has_order=False, has_cart=False, blocked=False) + ).filter(has_order=False, has_cart=False, has_voucher=False, blocked=False) @property def presale_has_ended(self): @@ -986,9 +998,19 @@ class SubEvent(EventMixin, LoggedModel): def __str__(self): return '{} - {}'.format(self.name, self.get_date_range_display()) - @property - def free_seats(self): + def free_seats(self, ignore_voucher=None): from .orders import CartPosition, Order, OrderPosition + from .vouchers import Voucher + vqs = Voucher.objects.filter( + event_id=self.event_id, + subevent=self, + seat_id=OuterRef('pk'), + redeemed__lt=F('max_usages'), + ).filter( + Q(valid_until__isnull=True) | Q(valid_until__gte=now()) + ) + if ignore_voucher: + vqs = vqs.exclude(pk=ignore_voucher.pk) return self.seats.annotate( has_order=Exists( OrderPosition.objects.filter( @@ -1005,8 +1027,11 @@ class SubEvent(EventMixin, LoggedModel): seat_id=OuterRef('pk'), expires__gte=now() ) + ), + has_voucher=Exists( + vqs ) - ).filter(has_order=False, has_cart=False, blocked=False) + ).filter(has_order=False, has_cart=False, blocked=False, has_voucher=False) @cached_property def settings(self): diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index bc5600415..3ac6fd33b 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -5,6 +5,7 @@ import jsonschema from django.contrib.staticfiles import finders from django.core.exceptions import ValidationError from django.db import models +from django.db.models import F, Q from django.utils.deconstruct import deconstructible from django.utils.timezone import now from django.utils.translation import gettext, ugettext_lazy as _ @@ -110,15 +111,21 @@ class Seat(models.Model): return self.name return ', '.join(parts) - def is_available(self, ignore_cart=None, ignore_orderpos=None): + def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None): from .orders import Order if self.blocked: return False opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) cpqs = self.cartposition_set.filter(expires__gte=now()) - if ignore_cart: + vqs = self.vouchers.filter( + Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now())) & + Q(redeemed__lt=F('max_usages')) + ) + if ignore_cart and ignore_cart is not True: cpqs = cpqs.exclude(pk=ignore_cart.pk) if ignore_orderpos: opqs = opqs.exclude(pk=ignore_orderpos.pk) - return not opqs.exists() and not cpqs.exists() + if ignore_voucher_id: + vqs = vqs.exclude(pk=ignore_voucher_id) + return not opqs.exists() and (ignore_cart is True or not cpqs.exists()) and not vqs.exists() diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 09c471136..0fe39d715 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -11,7 +11,7 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled from pretix.base.banlist import banned -from pretix.base.models import SeatCategoryMapping +from pretix.base.models import Seat, SeatCategoryMapping from ..decimal import round_decimal from .base import LoggedModel @@ -171,6 +171,12 @@ class Voucher(LoggedModel): "If enabled, the voucher is valid for any product affected by this quota." ) ) + seat = models.ForeignKey( + Seat, related_name='vouchers', + null=True, blank=True, + on_delete=models.PROTECT, + verbose_name=_("Specific seat"), + ) tag = models.CharField( max_length=255, verbose_name=_("Tag"), @@ -335,6 +341,38 @@ class Voucher(LoggedModel): if 'code' in data and Voucher.objects.filter(Q(code__iexact=data['code']) & Q(event=event) & ~Q(pk=pk)).exists(): raise ValidationError(_('A voucher with this code already exists.')) + @staticmethod + def clean_seat_id(data, item, event, pk): + try: + if event.has_subevents: + if not data.get('subevent'): + raise ValidationError(_('You need to choose a date if you select a seat.')) + seat = event.seats.get(seat_guid=data.get('seat'), subevent=data.get('subevent')) + else: + seat = event.seats.get(seat_guid=data.get('seat')) + except Seat.DoesNotExist: + raise ValidationError(_('The specified seat ID "{id}" does not exist for this event.').format( + id=data.get('seat'))) + + if not seat.is_available(ignore_voucher_id=pk, ignore_cart=True): + raise ValidationError(_('The seat "{id}" is currently unavailable (blocked, already sold or a ' + 'different voucher).').format( + id=seat.seat_guid)) + + if not item: + raise ValidationError(_('You need to choose a specific product if you select a seat.')) + + if data.get('max_usages', 1) > 1: + raise ValidationError(_('Seat-specific vouchers can only be used once.')) + + if seat.product != item: + raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product)) + + if not seat.is_available(ignore_voucher_id=pk): + raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid)) + + return seat + def save(self, *args, **kwargs): self.code = self.code.upper() super().save(*args, **kwargs) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index d35b04814..f92c754fa 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -80,6 +80,7 @@ error_messages = { 'cart if you want to use it for a different product.'), 'voucher_expired': _('This voucher is expired.'), 'voucher_invalid_item': _('This voucher is not valid for this product.'), + 'voucher_invalid_seat': _('This voucher is not valid for this seat.'), 'voucher_item_not_available': _( 'Your voucher is valid for a product that is currently not for sale.'), 'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'), @@ -252,6 +253,9 @@ class CartManager: if op.voucher and not op.voucher.applies_to(op.item, op.variation): raise CartError(error_messages['voucher_invalid_item']) + if op.voucher and op.voucher.seat and op.voucher.seat != op.seat: + raise CartError(error_messages['voucher_invalid_seat']) + if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk: raise CartError(error_messages['voucher_invalid_subevent']) @@ -824,7 +828,7 @@ class CartManager: available_count = 0 if isinstance(op, self.AddOperation): - if op.seat and not op.seat.is_available(): + if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None): available_count = 0 err = err or error_messages['seat_unavailable'] @@ -872,7 +876,8 @@ 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, + ignore_voucher_id=op.position.voucher_id): err = err or error_messages['seat_unavailable'] op.position.addons.all().delete() op.position.delete() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index d0d6c0284..c91323bdd 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -509,9 +509,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio break 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) or cp.seat.blocked: + # 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: err = err or error_messages['seat_unavailable'] cp.delete() continue diff --git a/src/pretix/base/services/seating.py b/src/pretix/base/services/seating.py index 8094bd9ab..739ba7d03 100644 --- a/src/pretix/base/services/seating.py +++ b/src/pretix/base/services/seating.py @@ -10,10 +10,9 @@ class SeatProtected(Exception): def validate_plan_change(event, subevent, plan): current_taken_seats = set( - event.seats.select_related('product') - .annotate(has_op=Count('orderposition')) - .filter(subevent=subevent, has_op=True) - .values_list('seat_guid', flat=True) + event.seats.select_related('product').annotate( + has_op=Count('orderposition') + ).annotate(has_v=Count('vouchers')).filter(subevent=subevent, has_op=True).values_list('seat_guid', flat=True) ) new_seats = { ss.guid for ss in plan.iter_all_seats() @@ -26,7 +25,9 @@ def validate_plan_change(event, subevent, plan): def generate_seats(event, subevent, plan, mapping): current_seats = {} - for s in event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent): + for s in event.seats.select_related('product').annotate( + has_op=Count('orderposition'), has_v=Count('vouchers') + ).filter(subevent=subevent): if s.seat_guid in current_seats: s.delete() # Duplicates should not exist else: diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index ba72ba560..00b37bc0b 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -38,7 +38,7 @@ class VoucherForm(I18nModelForm): localized_fields = '__all__' fields = [ 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', - 'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items' + 'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', ] field_classes = { 'valid_until': SplitDateTimeField, @@ -115,6 +115,16 @@ class VoucherForm(I18nModelForm): self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices self.fields['itemvar'].required = True + if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists(): + self.fields['seat'] = forms.CharField( + label=_("Specific seat ID"), + max_length=255, + required=False, + widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}), + initial=self.instance.seat.seat_guid if self.instance.seat else '', + help_text=str(self.instance.seat) if self.instance.seat else '', + ) + def clean(self): data = super().clean() @@ -179,6 +189,8 @@ class VoucherForm(I18nModelForm): self.instance.quota, self.instance.item, self.instance.variation ) Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk) + if 'seat' in self.fields and data.get('seat'): + self.instance.seat = Voucher.clean_seat_id(data, self.instance.item, self.instance.event, self.instance.pk) voucher_form_validation.send(sender=self.instance.event, form=self, data=data) @@ -271,6 +283,13 @@ class VoucherBulkForm(VoucherForm): super().__init__(*args, **kwargs) self._set_field_placeholders('send_subject', ['event', 'name']) self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name']) + if 'seat' in self.fields: + self.fields['seats'] = forms.CharField( + label=_("Specific seat IDs"), + required=False, + widget=forms.Textarea(attrs={'data-seat-guid-field': '1'}), + initial=self.instance.seat.seat_guid if self.instance.seat else '', + ) def clean_send_recipients(self): raw = self.cleaned_data['send_recipients'] @@ -331,6 +350,18 @@ class VoucherBulkForm(VoucherForm): if code_len != recp_len: raise ValidationError(_('You generated {codes} vouchers, but entered recipients for {recp} vouchers.').format(codes=code_len, recp=recp_len)) + if data.get('seats'): + seatids = [s.strip() for s in data.get('seats').strip().split() if s] + print(seatids) + if len(seatids) != len(data.get('codes')): + raise ValidationError(_('You need to specify as many seats as voucher codes.')) + data['seats'] = [] + for s in seatids: + data['seat'] = s + data['seats'].append(Voucher.clean_seat_id(data, self.instance.item, self.instance.event, None)) + else: + data['seats'] = [] + return data def save(self, event, *args, **kwargs): @@ -339,6 +370,10 @@ class VoucherBulkForm(VoucherForm): obj = modelcopy(self.instance) obj.event = event obj.code = code + try: + obj.seat = self.cleaned_data['seats'].pop() + except IndexError: + pass data = dict(self.cleaned_data) data['code'] = code data['bulk'] = True diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index f5a9f634f..083c6b7d0 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -65,6 +65,9 @@ {% if form.subevent %} {% bootstrap_field form.subevent layout="control" %} {% endif %} + {% if "seats" in form.fields %} + {% bootstrap_field form.seats layout="control" %} + {% endif %}
{% trans "Advanced settings" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html index 7b18faa55..55e06388f 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html @@ -67,6 +67,9 @@ {% if form.subevent %} {% bootstrap_field form.subevent layout="control" %} {% endif %} + {% if "seat" in form.fields %} + {% bootstrap_field form.seat layout="control" %} + {% endif %}
{% trans "Advanced settings" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/vouchers/index.html index b8a2dabee..4eb854711 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/index.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/index.html @@ -133,6 +133,7 @@ Any product in quota "{{ quota }}" {% endblocktrans %} {% endif %} + {% if v.seat %}
{{ v.seat }}{% endif %} {% if request.event.has_subevents %} {{ v.subevent.name }} – {{ v.subevent.get_date_range_display }} diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 4cbd3b1bb..38caa2bf6 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -235,11 +235,11 @@ def seat_select2(request, **kwargs): if request.event.has_subevents: try: - qs = request.event.subevents.get(active=True, pk=request.GET.get('subevent', 0)).free_seats + qs = request.event.subevents.get(active=True, pk=request.GET.get('subevent', 0)).free_seats() except SubEvent.DoesNotExist: qs = request.event.seats.none() else: - qs = request.event.free_seats + qs = request.event.free_seats() qs = qs.filter( Q(name__icontains=query) | Q(seat_guid__icontains=query) ).order_by('name').select_related('product', 'subevent') diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index 7bf4de5ed..f8d774dbd 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -37,7 +37,9 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): permission = 'can_view_vouchers' def get_queryset(self): - qs = self.request.event.vouchers.filter(waitinglistentries__isnull=True).select_related('item', 'variation') + qs = self.request.event.vouchers.filter(waitinglistentries__isnull=True).select_related( + 'item', 'variation', 'seat' + ) if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index b0a12a3e9..96c2f65b2 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pytz import UTC -from pretix.base.models import Event, Voucher +from pretix.base.models import Event, SeatingPlan, Voucher @pytest.fixture @@ -44,7 +44,8 @@ TEST_VOUCHER_RES = { 'tag': 'Foo', 'comment': '', 'show_hidden_items': True, - 'subevent': None + 'subevent': None, + 'seat': None, } @@ -1049,3 +1050,223 @@ def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, assert resp.data == [{}, {'code': ['Duplicate voucher code in request.']}] with scopes_disabled(): assert Voucher.objects.count() == 0 + + +@pytest.fixture +def seatingplan(organizer, event): + plan = SeatingPlan.objects.create( + name="Plan", organizer=organizer, layout="{}" + ) + event.seating_plan = plan + event.save() + return plan + + +@pytest.fixture +def seat1(item, event): + return event.seats.create(name="A1", product=item, seat_guid="A1") + + +@pytest.mark.django_db +def test_create_multiple_vouchers_duplicate_seat(token_client, organizer, event, item, seat1, seatingplan): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), + data=[ + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': False, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None, + 'seat': 'A1', + }, + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': True, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None, + 'seat': 'A1', + } + ], format='json' + ) + assert resp.status_code == 400 + assert resp.data == [{}, {'code': ['Duplicate seat ID in request.']}] + with scopes_disabled(): + assert Voucher.objects.count() == 0 + + +@pytest.mark.django_db +def test_set_seat_ok(token_client, organizer, event, seatingplan, seat1, item): + with scopes_disabled(): + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + ) + with scopes_disabled(): + v.refresh_from_db() + assert v.seat == seat1 + + +@pytest.mark.django_db +def test_save_set_seat(token_client, organizer, event, seatingplan, seat1, item): + with scopes_disabled(): + v = event.vouchers.create(item=item, seat=seat1) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + ) + with scopes_disabled(): + v.refresh_from_db() + assert v.seat == seat1 + + +@pytest.mark.django_db +def test_set_seat_unknown(token_client, organizer, event, seatingplan, seat1, item): + with scopes_disabled(): + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'unknown' + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_seat_seat_productmissing(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + v = event.vouchers.create(quota=quota) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_seat_seat_productwrong(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + i2 = event.items.create(name="Budget Ticket", default_price=23) + v = event.vouchers.create(item=i2) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_seat_seat_usages(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + v = event.vouchers.create(item=item, max_usages=2) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_seat_seat_duplicate(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + event.vouchers.create(item=item, seat=seat1) + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1' + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_set_seat_subevent(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + se2 = event.subevents.create(name="Baz", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + seat1 = event.seats.create(name="A1", product=item, seat_guid="A1", subevent=se1) + event.seats.create(name="A1", product=item, seat_guid="A1", subevent=se2) + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1', + 'subevent': se1.pk + }, + ) + with scopes_disabled(): + v.refresh_from_db() + assert v.seat == seat1 + assert v.subevent == se1 + + +@pytest.mark.django_db +def test_set_seat_subevent_required(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + se2 = event.subevents.create(name="Baz", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + seat1 = event.seats.create(name="A1", product=item, seat_guid="A1", subevent=se1) + event.seats.create(name="A1", product=item, seat_guid="A1", subevent=se2) + event.vouchers.create(item=item, seat=seat1) + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1', + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_set_seat_subevent_invalid(token_client, organizer, event, seatingplan, seat1, item, quota): + with scopes_disabled(): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + se2 = event.subevents.create(name="Baz", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + seat1 = event.seats.create(name="A1", product=item, seat_guid="A1", subevent=se1) + event.seats.create(name="B1", product=item, seat_guid="B1", subevent=se2) + event.vouchers.create(item=item, seat=seat1, subevent=se2) + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'seat': 'A1', + }, + expected_failure=True + ) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index e5db1752e..2e137f8da 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -2020,7 +2020,7 @@ class SeatingTestCase(TestCase): @classscope(attr='organizer') def test_free(self): - assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a1, self.seat_a2} assert self.seat_a1.is_available() assert self.seat_a2.is_available() @@ -2028,7 +2028,7 @@ class SeatingTestCase(TestCase): def test_blocked(self): self.seat_a1.blocked = True self.seat_a1.save() - assert set(self.event.free_seats) == {self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a2} assert not self.seat_a1.is_available() assert self.seat_a2.is_available() @@ -2043,7 +2043,7 @@ class SeatingTestCase(TestCase): order=o, item=self.ticket, variation=None, price=Decimal("12"), seat=self.seat_a1 ) - assert set(self.event.free_seats) == {self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a2} assert not self.seat_a1.is_available() @classscope(attr='organizer') @@ -2057,7 +2057,7 @@ class SeatingTestCase(TestCase): order=o, item=self.ticket, variation=None, price=Decimal("12"), seat=self.seat_a1 ) - assert set(self.event.free_seats) == {self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a2} assert not self.seat_a1.is_available() @classscope(attr='organizer') @@ -2071,7 +2071,7 @@ class SeatingTestCase(TestCase): order=o, item=self.ticket, variation=None, price=Decimal("12"), seat=self.seat_a1 ) - assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a1, self.seat_a2} assert self.seat_a1.is_available() @classscope(attr='organizer') @@ -2080,7 +2080,7 @@ class SeatingTestCase(TestCase): event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1, price=23, expires=now() + timedelta(minutes=10) ) - assert set(self.event.free_seats) == {self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a2} assert not self.seat_a1.is_available() @classscope(attr='organizer') @@ -2089,7 +2089,7 @@ class SeatingTestCase(TestCase): event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1, price=23, expires=now() - timedelta(minutes=10) ) - assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2} + assert set(self.event.free_seats()) == {self.seat_a1, self.seat_a2} assert self.seat_a1.is_available() @classscope(attr='organizer') @@ -2106,7 +2106,7 @@ class SeatingTestCase(TestCase): order=o, item=self.ticket, variation=None, price=Decimal("12"), seat=self.seat_a1, subevent=se1 ) - assert set(se1.free_seats) == set() + assert set(se1.free_seats()) == set() assert not self.seat_a1.is_available() @classscope(attr='organizer') @@ -2123,7 +2123,7 @@ class SeatingTestCase(TestCase): order=o, item=self.ticket, variation=None, price=Decimal("12"), seat=self.seat_a1, subevent=se1 ) - assert set(se1.free_seats) == {self.seat_a1} + assert set(se1.free_seats()) == {self.seat_a1} assert self.seat_a1.is_available() @classscope(attr='organizer') @@ -2135,7 +2135,7 @@ class SeatingTestCase(TestCase): event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1, price=23, expires=now() + timedelta(minutes=10), subevent=se1 ) - assert set(se1.free_seats) == set() + assert set(se1.free_seats()) == set() assert not self.seat_a1.is_available() @classscope(attr='organizer') @@ -2147,7 +2147,25 @@ class SeatingTestCase(TestCase): event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1, price=23, expires=now() - timedelta(minutes=10), subevent=se1 ) - assert set(se1.free_seats) == {self.seat_a1} + assert set(se1.free_seats()) == {self.seat_a1} + assert self.seat_a1.is_available() + + @classscope(attr='organizer') + def test_voucher_active(self): + Voucher.objects.create( + event=self.event, code='a', item=self.ticket, seat=self.seat_a1, + valid_until=now() + timedelta(minutes=10) + ) + assert set(self.event.free_seats()) == {self.seat_a2} + assert not self.seat_a1.is_available() + + @classscope(attr='organizer') + def test_voucher_expired(self): + Voucher.objects.create( + event=self.event, code='a', item=self.ticket, seat=self.seat_a1, + valid_until=now() - timedelta(minutes=10) + ) + assert set(self.event.free_seats()) == {self.seat_a2, self.seat_a1} assert self.seat_a1.is_available() diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 85cf8324a..94f03124d 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -2918,6 +2918,30 @@ class CartSeatingTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_add_specific_voucher(self): + with scopes_disabled(): + v = self.event.vouchers.create(item=self.ticket, seat=self.seat_a1) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'seat_%d' % self.ticket.id: self.seat_a1, + '_voucher_code': v.code, + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].voucher, v) + self.assertEqual(objs[0].seat, self.seat_a1) + + def test_add_specific_voucher_wrong_seat(self): + with scopes_disabled(): + v = self.event.vouchers.create(item=self.ticket, seat=self.seat_a1) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'seat_%d' % self.ticket.id: self.seat_a2, + '_voucher_code': v.code, + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_add_seat_unknown(self): self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 'seat_%d' % self.ticket.id: 'asdasdasd',