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 %}