diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index 707d6f1ad0..0008a0f7c0 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -153,7 +153,8 @@ Endpoints :param organizer: The ``slug`` field of the organizer to create a tax rule for :param event: The ``slug`` field of the event to create a tax rule for - :statuscode 200: no error + :statuscode 201: no error + :statuscode 400: The tax rule could not be created due to invalid submitted data. :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create tax rules. @@ -199,6 +200,7 @@ Endpoints :param event: The ``slug`` field of the event to modify :param id: The ``id`` field of the tax rule to modify :statuscode 200: no error + :statuscode 400: The tax rule could not be modified due to invalid submitted data. :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it. @@ -223,12 +225,11 @@ Endpoints HTTP/1.1 204 No Content Vary: Accept - Content-Type: text/javascript :param organizer: The ``slug`` field of the organizer to modify :param event: The ``slug`` field of the event to modify :param id: The ``id`` field of the tax rule to delete - :statuscode 200: no error + :statuscode 204: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use. diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index fadac0989e..dd5f20d736 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -162,3 +162,150 @@ Endpoints :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/ + + Create a new voucher. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 408 + + { + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "", + "subevent": null + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "", + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer to create a voucher for + :param event: The ``slug`` field of the event to create a voucher for + :statuscode 201: no error + :statuscode 400: The voucher could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ + + Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be resetted to default. With ``PATCH``, you only need to provide the fields that you + want to change. +her. + + You can change all fields of the resource except the ``id`` and ``redeemed`` fields. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/vouchers/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 408 + + { + "price_mode": "set", + "value": "24.00", + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "24.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "", + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the tax rule to modify + :statuscode 200: no error + :statuscode 400: The voucher could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ + + Delete a voucher. Note that you cannot delete a voucher if it already has been redeemed. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/vouchers/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the tax rule to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index 1feab49612..5a8f4c0e30 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -8,3 +8,36 @@ class VoucherSerializer(I18nAwareModelSerializer): fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'comment', 'subevent') + read_only_fields = ('id', 'redeemed') + + def validate(self, data): + data = super().validate(data) + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + Voucher.clean_item_properties( + full_data, self.context.get('event'), + full_data.get('quota'), full_data.get('item'), full_data.get('variation') + ) + Voucher.clean_subevent( + full_data, self.context.get('event') + ) + Voucher.clean_max_usages(full_data, self.instance.redeemed if self.instance else 0) + check_quota = Voucher.clean_quota_needs_checking( + full_data, self.instance, + item_changed=self.instance and ( + full_data.get('item') != self.instance.item or + full_data.get('variation') != self.instance.variation or + full_data.get('quota') != self.instance.quota + ), + creating=not self.instance + ) + if check_quota: + Voucher.clean_quota_check( + full_data, 1, self.instance, self.context.get('event'), + full_data.get('quota'), full_data.get('item'), full_data.get('variation') + ) + Voucher.clean_voucher_code(full_data, 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 45c3a01e43..bdcc86c2f1 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -4,10 +4,12 @@ from django_filters.rest_framework import ( BooleanFilter, DjangoFilterBackend, FilterSet, ) from rest_framework import viewsets +from rest_framework.exceptions import PermissionDenied from rest_framework.filters import OrderingFilter from pretix.api.serializers.voucher import VoucherSerializer from pretix.base.models import Voucher +from pretix.base.models.organizer import TeamAPIToken class VoucherFilter(FilterSet): @@ -27,7 +29,7 @@ class VoucherFilter(FilterSet): (Q(valid_until__isnull=False) & Q(valid_until__lte=now()))) -class VoucherViewSet(viewsets.ReadOnlyModelViewSet): +class VoucherViewSet(viewsets.ModelViewSet): serializer_class = VoucherSerializer queryset = Voucher.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -35,6 +37,49 @@ class VoucherViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value') filter_class = VoucherFilter permission = 'can_view_vouchers' + write_permission = 'can_change_vouchers' def get_queryset(self): return self.request.event.vouchers.all() + + def create(self, request, *args, **kwargs): + with request.event.lock(): + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.voucher.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + + def update(self, request, *args, **kwargs): + with request.event.lock(): + return super().update(request, *args, **kwargs) + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.voucher.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied('This voucher can not be deleted as it has already been used.') + + instance.log_action( + 'pretix.voucher.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + super().perform_destroy(instance) diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 8aeeae66a0..2f4b0e537c 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -4,6 +4,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.utils.crypto import get_random_string from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ @@ -180,24 +181,136 @@ class Voucher(LoggedModel): def __str__(self): return self.code + def allow_delete(self): + return self.redeemed == 0 + def clean(self): - super().clean() - if self.quota: - if self.item: + Voucher.clean_item_properties( + { + 'block_quota': self.block_quota, + }, + self.event, + self.quota, + self.item, + self.variation + ) + + @staticmethod + def clean_item_properties(data, event, quota, item, variation): + if quota: + if item: raise ValidationError(_('You cannot select a quota and a specific product at the same time.')) - elif self.item: - if self.variation and (not self.item or not self.item.has_variations): + elif item: + if variation and (not item or not item.has_variations): raise ValidationError(_('You cannot select a variation without having selected a product that provides ' 'variations.')) - if self.variation and not self.item.variations.filter(pk=self.variation.pk).exists(): + if variation and not item.variations.filter(pk=variation.pk).exists(): raise ValidationError(_('This variation does not belong to this product.')) - if self.item.has_variations and not self.variation and self.block_quota: + if item.has_variations and not variation and data.get('block_quota'): raise ValidationError(_('You can only block quota if you specify a specific product variation. ' 'Otherwise it might be unclear which quotas to block.')) + if item.category and item.category.is_addon: + raise ValidationError(_('It is currently not possible to create vouchers for add-on products.')) else: raise ValidationError(_('You need to specify either a quota or a product.')) - if self.event.has_subevents and self.block_quota and not self.subevent: + + @staticmethod + def clean_max_usages(data, redeemed): + if data.get('max_usages', 1) < redeemed: + raise ValidationError( + _('This voucher has already been redeemed %(redeemed)s times. You cannot reduce the maximum number of ' + 'usages below this number.'), + params={ + 'redeemed': redeemed + } + ) + + @staticmethod + def clean_subevent(data, event): + if event.has_subevents and data.get('block_quota') and not data.get('subevent'): raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) + elif data.get('subevent') and not event.has_subevents: + raise ValidationError(_('You can not select a subevent if your event is not an event series.')) + + @staticmethod + def clean_quota_needs_checking(data, old_instance, item_changed, creating): + # We only need to check for quota on vouchers that are now blocking quota and haven't + # before (or have blocked a different quota before) + if data.get('block_quota', False): + is_valid = data.get('valid_until') is None or data.get('valid_until') >= now() + if not is_valid: + # If the voucher is not valid, it won't block any quota + return False + + if creating: + # This is a new voucher + return True + + if not old_instance.block_quota: + # Change from nonblocking to blocking + return True + + if old_instance.valid_until is not None and old_instance.valid_until < now(): + # This voucher has been expired and is now valid again and therefore blocks quota again + return True + + if item_changed: + # The voucher has been reassigned to a different item, variation or quota + return True + + if data.get('subevent') != old_instance.subevent: + # The voucher has been reassigned to a different subevent + return True + + return False + + @staticmethod + def clean_quota_get_ignored(old_instance): + quotas = set() + was_valid = old_instance and ( + old_instance.valid_until is None or old_instance.valid_until >= now() + ) + if old_instance and old_instance.block_quota and was_valid: + if old_instance.quota: + quotas.add(old_instance.quota) + elif old_instance.variation: + quotas |= set(old_instance.variation.quotas.filter( + subevent=old_instance.subevent)) + elif old_instance.item: + quotas |= set(old_instance.item.quotas.filter( + subevent=old_instance.subevent)) + return quotas + + @staticmethod + def clean_quota_check(data, cnt, old_instance, event, quota, item, variation): + old_quotas = Voucher.clean_quota_get_ignored(old_instance) + + if event.has_subevents and data.get('block_quota') and not data.get('subevent'): + raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) + + if quota: + if quota in old_quotas: + return + else: + avail = quota.availability(count_waitinglist=False) + elif item and item.has_variations and not variation: + raise ValidationError(_('You can only block quota if you specify a specific product variation. ' + 'Otherwise it might be unclear which quotas to block.')) + elif item and variation: + avail = variation.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) + elif item and not item.has_variations: + avail = item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) + else: + raise ValidationError(_('You need to specify either a quota or a product.')) + + if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < cnt): + raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or ' + 'quota is currently sold out or completely reserved.')) + + @staticmethod + def clean_voucher_code(data, event, pk): + if 'code' in data and Voucher.objects.filter(Q(code=data['code']) & Q(event=event) & ~Q(pk=pk)).exists(): + raise ValidationError(_('A voucher with this code already exists.')) def save(self, *args, **kwargs): self.code = self.code.upper() diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index c0236a2da7..39ae0643f4 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -2,9 +2,7 @@ import copy from django import forms from django.core.exceptions import ValidationError -from django.db.models import Q -from django.utils.timezone import now -from django.utils.translation import pgettext_lazy, ugettext_lazy as _ +from django.utils.translation import ugettext_lazy as _ from pretix.base.forms import I18nModelForm from pretix.base.models import Item, ItemVariation, Quota, Voucher @@ -92,113 +90,41 @@ class VoucherForm(I18nModelForm): self.instance.variation = None self.instance.quota = None - if self.instance.item.category and self.instance.item.category.is_addon: - raise ValidationError(_('It is currently not possible to create vouchers for add-on products.')) else: self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event) self.instance.item = None self.instance.variation = None - if data.get('max_usages', 0) < self.instance.redeemed: - raise ValidationError( - _('This voucher has already been redeemed %(redeemed)s times. You cannot reduce the maximum number of ' - 'usages below this number.'), - params={ - 'redeemed': self.instance.redeemed - } - ) - if 'codes' in data: data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a] cnt = len(data['codes']) * data['max_usages'] else: cnt = data['max_usages'] - if self.instance.event.has_subevents and data['block_quota'] and not data.get('subevent'): - raise ValidationError(pgettext_lazy( - 'subevent', - 'If you want this voucher to block quota, you need to select a specific date.' - )) - - if self._clean_quota_needs_checking(data): - self._clean_quota_check(data, cnt) - - if 'code' in data and Voucher.objects.filter(Q(code=data['code']) & Q(event=self.instance.event) & ~Q(pk=self.instance.pk)).exists(): - raise ValidationError(_('A voucher with this code already exists.')) + Voucher.clean_item_properties( + data, self.instance.event, + self.instance.quota, self.instance.item, self.instance.variation + ) + Voucher.clean_subevent( + data, self.instance.event + ) + Voucher.clean_max_usages(data, self.instance.redeemed) + check_quota = Voucher.clean_quota_needs_checking( + data, self.initial_instance_data, + item_changed=data.get('itemvar') != self.initial.get('itemvar'), + creating=not self.instance.pk + ) + if check_quota: + Voucher.clean_quota_check( + data, cnt, self.initial_instance_data, self.instance.event, + self.instance.quota, self.instance.item, self.instance.variation + ) + Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk) voucher_form_validation.send(sender=self.instance.event, form=self, data=data) return data - def _clean_quota_needs_checking(self, data): - # We only need to check for quota on vouchers that are now blocking quota and haven't - # before (or have blocked a different quota before) - if data.get('block_quota', False): - is_valid = data.get('valid_until') is None or data.get('valid_until') >= now() - if not is_valid: - # If the voucher is not valid, it won't block any quota - return False - - if not self.instance.pk: - # This is a new voucher - return True - - if not self.initial_instance_data.block_quota: - # Change from nonblocking to blocking - return True - - if not self._clean_was_valid(): - # This voucher has been expired and is now valid again and therefore blocks quota again - return True - - if data.get('itemvar') != self.initial.get('itemvar'): - # The voucher has been reassigned to a different item, variation or quota - return True - - if data.get('subevent') != self.initial.get('subevent'): - # The voucher has been reassigned to a different subevent - return True - - return False - - def _clean_was_valid(self): - return self.initial_instance_data.valid_until is None or self.initial_instance_data.valid_until >= now() - - def _clean_quota_get_ignored(self): - quotas = set() - if self.initial_instance_data and self.initial_instance_data.block_quota and self._clean_was_valid(): - if self.initial_instance_data.quota: - quotas.add(self.initial_instance_data.quota) - elif self.initial_instance_data.variation: - quotas |= set(self.initial_instance_data.variation.quotas.filter( - subevent=self.initial_instance_data.subevent)) - elif self.initial_instance_data.item: - quotas |= set(self.initial_instance_data.item.quotas.filter( - subevent=self.initial_instance_data.subevent)) - return quotas - - def _clean_quota_check(self, data, cnt): - old_quotas = self._clean_quota_get_ignored() - - if self.instance.quota: - if self.instance.quota in old_quotas: - return - else: - avail = self.instance.quota.availability(count_waitinglist=False) - elif self.instance.item and self.instance.item.has_variations and not self.instance.variation: - raise ValidationError(_('You can only block quota if you specify a specific product variation. ' - 'Otherwise it might be unclear which quotas to block.')) - elif self.instance.item and self.instance.variation: - avail = self.instance.variation.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) - elif self.instance.item and not self.instance.item.has_variations: - avail = self.instance.item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) - else: - raise ValidationError(_('You need to specify either a quota or a product.')) - - if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < cnt): - raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or ' - 'quota is currently sold out or completely reserved.')) - def save(self, commit=True): super().save(commit) diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index 5898ad8202..033ac202d4 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -126,7 +126,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView): raise Http404(_("The requested voucher does not exist.")) def get(self, request, *args, **kwargs): - if self.get_object().redeemed > 0: + if not self.get_object().allow_delete(): messages.error(request, _('A voucher can not be deleted if it already has been redeemed.')) return HttpResponseRedirect(self.get_success_url()) return super().get(request, *args, **kwargs) @@ -136,7 +136,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView): self.object = self.get_object() success_url = self.get_success_url() - if self.object.redeemed > 0: + if not self.object.allow_delete(): messages.error(request, _('A voucher can not be deleted if it already has been redeemed.')) else: self.object.log_action('pretix.voucher.deleted', user=self.request.user) diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 7c7810f6e8..64b09d054d 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -38,7 +38,9 @@ def team(organizer): return Team.objects.create( organizer=organizer, can_change_items=True, - can_change_event_settings=True + can_change_event_settings=True, + can_change_vouchers=True, + can_view_vouchers=True, ) diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 2e258dd0e4..582d6249d8 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -30,6 +30,10 @@ event_permission_urls = [ ('put', 'can_change_event_settings', 'taxrules/1/', 404), ('patch', 'can_change_event_settings', 'taxrules/1/', 404), ('delete', 'can_change_event_settings', 'taxrules/1/', 404), + ('post', 'can_change_vouchers', 'vouchers/', 400), + ('put', 'can_change_vouchers', 'vouchers/1/', 404), + ('patch', 'can_change_vouchers', 'vouchers/1/', 404), + ('delete', 'can_change_vouchers', 'vouchers/1/', 404), ] diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index 1628f33012..470cb57bee 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -1,7 +1,11 @@ import datetime +from decimal import Decimal import pytest from django.utils import timezone +from django.utils.timezone import now + +from pretix.base.models import Voucher @pytest.fixture @@ -212,3 +216,634 @@ def test_voucher_detail(token_client, organizer, event, voucher, item): voucher.pk)) assert resp.status_code == 200 assert res == resp.data + + +def create_voucher(token_client, organizer, event, data, expected_failure=False): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/vouchers/'.format(organizer.slug, event.slug), + data=data, format='json' + ) + if expected_failure: + assert resp.status_code == 400 + else: + assert resp.status_code == 201 + return Voucher.objects.get(pk=resp.data['id']) + + +@pytest.mark.django_db +def test_voucher_require_item(token_client, organizer, event, item): + create_voucher( + token_client, organizer, event, + data={}, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_voucher_create_minimal(token_client, organizer, event, item): + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + }, + ) + assert v.item == item + + +@pytest.mark.django_db +def test_voucher_create_full(token_client, organizer, event, item): + v = create_voucher( + token_client, organizer, event, + 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 + }, + ) + + assert v.code == 'ABCDEFGHI' + assert v.max_usages == 1 + assert v.redeemed == 0 + assert v.valid_until is None + assert v.max_usages == 1 + assert v.block_quota is False + assert v.price_mode == 'set' + assert v.value == Decimal('12.00') + assert v.item == item + assert v.variation is None + assert v.quota is None + assert v.tag == 'Foo' + assert v.subevent is None + + +@pytest.mark.django_db +def test_voucher_create_for_addon_item(token_client, organizer, event, item): + c = event.categories.create(name="Foo", is_addon=True) + item.category = c + item.save() + create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + }, expected_failure=True + ) + + +@pytest.mark.django_db +def test_create_non_blocking_item_voucher(token_client, organizer, event, item): + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + } + ) + assert v.item == item + assert v.variation is None + assert v.quota is None + + +@pytest.mark.django_db +def test_create_non_blocking_variation_voucher(token_client, organizer, event, item): + variation = item.variations.create(value="XL") + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'variation': variation.pk + } + ) + assert v.item == variation.item + assert v.variation == variation + assert v.quota is None + + +@pytest.mark.django_db +def test_create_non_blocking_quota_voucher(token_client, organizer, event, quota): + v = create_voucher( + token_client, organizer, event, + data={ + 'quota': quota.pk + } + ) + assert not v.block_quota + assert v.quota == quota + assert v.item is None + + +@pytest.mark.django_db +def test_create_blocking_item_voucher_quota_free(token_client, organizer, event, item, quota): + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True + } + ) + assert v.block_quota + + +@pytest.mark.django_db +def test_create_blocking_item_voucher_quota_full(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_create_blocking_item_voucher_quota_full_invalid(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True, + 'valid_until': (now() - datetime.timedelta(days=3)).isoformat() + } + ) + assert v.block_quota + assert not v.is_active() + + +@pytest.mark.django_db +def test_create_blocking_variation_voucher_quota_free(token_client, organizer, event, item, quota): + variation = item.variations.create(value="XL") + quota.variations.add(variation) + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'variation': variation.pk, + 'block_quota': True + } + ) + assert v.block_quota + + +@pytest.mark.django_db +def test_create_short_code(token_client, organizer, event, item): + create_voucher( + token_client, organizer, event, + data={ + 'code': 'ABC', + 'item': item.pk + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_create_blocking_variation_voucher_quota_full(token_client, organizer, event, item, quota): + variation = item.variations.create(value="XL") + quota.variations.add(variation) + quota.size = 0 + quota.save() + create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'variation': variation.pk, + 'block_quota': True + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_create_blocking_quota_voucher_quota_free(token_client, organizer, event, quota): + create_voucher( + token_client, organizer, event, + data={ + 'quota': quota.pk, + 'block_quota': True + }, + ) + + +@pytest.mark.django_db +def test_create_blocking_quota_voucher_quota_full(token_client, organizer, event, quota): + quota.size = 0 + quota.save() + create_voucher( + token_client, organizer, event, + data={ + 'quota': quota.pk, + 'block_quota': True + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_create_duplicate_code(token_client, organizer, event, quota): + v = event.vouchers.create(quota=quota) + create_voucher( + token_client, organizer, event, + data={ + 'quota': quota.pk, + 'code': v.code, + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_subevent_optional(token_client, organizer, event, item, subevent): + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + }, + ) + assert v.subevent is None + assert v.block_quota is False + assert v.item == item + + +@pytest.mark.django_db +def test_subevent_required_for_blocking(token_client, organizer, event, item, subevent): + create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_subevent_blocking_quota_free(token_client, organizer, event, item, quota, subevent): + se2 = event.subevents.create(name="Bar", date_from=now()) + quota.subevent = subevent + quota.save() + q2 = event.quotas.create(event=event, name='Tickets', size=0, subevent=se2) + q2.items.add(item) + + v = create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True, + 'subevent': subevent.pk + }, + ) + assert v.block_quota + assert v.subevent == subevent + + +@pytest.mark.django_db +def test_subevent_blocking_quota_full(token_client, organizer, event, item, quota, subevent): + se2 = event.subevents.create(name="Bar", date_from=now()) + quota.subevent = subevent + quota.size = 0 + quota.save() + q2 = event.quotas.create(event=event, name='Tickets', size=5, subevent=se2) + q2.items.add(item) + + create_voucher( + token_client, organizer, event, + data={ + 'item': item.pk, + 'block_quota': True, + 'subevent': subevent.pk + }, + expected_failure=True + ) + + +def change_voucher(token_client, organizer, event, voucher, data, expected_failure=False): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/vouchers/{}/'.format(organizer.slug, event.slug, voucher.pk), + data=data, format='json' + ) + if expected_failure: + assert resp.status_code == 400 + else: + assert resp.status_code == 200 + voucher.refresh_from_db() + + +@pytest.mark.django_db +def test_change_non_blocking_voucher(token_client, organizer, event, item, quota): + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'quota': quota.pk, + 'item': None + } + ) + assert v.item is None + assert v.quota == quota + + +@pytest.mark.django_db +def test_change_voucher_reduce_max_usages(token_client, organizer, event, item, quota): + v = event.vouchers.create(item=item, max_usages=5, redeemed=3) + change_voucher( + token_client, organizer, event, v, + data={ + 'max_usages': 2 + }, + expected_failure=True + ) + assert v.max_usages == 5 + + +@pytest.mark.django_db +def test_change_blocking_voucher_unchanged_quota_full(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + v = event.vouchers.create(item=item, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'comment': 'Foo' + } + ) + assert v.item == item + assert v.block_quota + assert v.comment == 'Foo' + + +@pytest.mark.django_db +def test_change_voucher_to_blocking_quota_full(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'block_quota': True + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_change_voucher_to_blocking_quota_free(token_client, organizer, event, item, quota): + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'block_quota': True + }, + ) + assert v.block_quota + + +@pytest.mark.django_db +def test_change_voucher_validity_to_valid_quota_full(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + v = event.vouchers.create(item=item, valid_until=now() - datetime.timedelta(days=3), + block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'valid_until': (now() + datetime.timedelta(days=3)).isoformat() + }, + expected_failure=True + ) + assert v.valid_until < now() + + +@pytest.mark.django_db +def test_change_voucher_validity_to_valid_quota_free(token_client, organizer, event, item, quota): + v = event.vouchers.create(item=item, valid_until=now() - datetime.timedelta(days=3), + block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'valid_until': (now() + datetime.timedelta(days=3)).isoformat() + }, + ) + assert v.valid_until > now() + + +@pytest.mark.django_db +def test_change_item_of_blocking_voucher_quota_free(token_client, organizer, event, item, quota): + ticket2 = event.items.create(name='Late-bird ticket', default_price=23) + quota.items.add(ticket2) + v = event.vouchers.create(item=item, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'item': ticket2.pk + }, + ) + assert v.item == ticket2 + + +@pytest.mark.django_db +def test_change_item_of_blocking_voucher_quota_full(token_client, organizer, event, item, quota): + ticket2 = event.items.create(name='Late-bird ticket', default_price=23) + quota2 = event.quotas.create(name='Late', size=0) + quota2.items.add(ticket2) + v = event.vouchers.create(item=item, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'item': ticket2.pk + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_change_variation_of_blocking_voucher_quota_free(token_client, organizer, event): + shirt = event.items.create(name='Shirt', default_price=23) + vs = shirt.variations.create(value='S') + vm = shirt.variations.create(value='M') + qs = event.quotas.create(name='S', size=2) + qs.variations.add(vs) + qm = event.quotas.create(name='M', size=2) + qm.variations.add(vm) + v = event.vouchers.create(item=shirt, variation=vs, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'variation': vm.pk + }, + ) + assert v.variation == vm + + +@pytest.mark.django_db +def test_change_variation_of_blocking_voucher_without_quota_change(token_client, organizer, event): + shirt = event.items.create(name='Shirt', default_price=23) + vs = shirt.variations.create(value='S') + vm = shirt.variations.create(value='M') + q = event.quotas.create(name='S', size=0) + q.variations.add(vs) + q.variations.add(vm) + v = event.vouchers.create(item=shirt, variation=vs, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'variation': vm.pk + } + ) + assert v.variation == vm + + +@pytest.mark.django_db +def test_change_variation_of_blocking_voucher_quota_full(token_client, organizer, event): + shirt = event.items.create(name='Shirt', default_price=23) + vs = shirt.variations.create(value='S') + vm = shirt.variations.create(value='M') + qs = event.quotas.create(name='S', size=2) + qs.variations.add(vs) + qm = event.quotas.create(name='M', size=0) + qm.variations.add(vm) + v = event.vouchers.create(item=shirt, variation=vs, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'variation': vm.pk + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_change_quota_of_blocking_voucher_quota_free(token_client, organizer, event): + qs = event.quotas.create(name='S', size=2) + qm = event.quotas.create(name='M', size=2) + v = event.vouchers.create(quota=qs, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'quota': qm.pk + }, + ) + assert v.quota == qm + + +@pytest.mark.django_db +def test_change_quota_of_blocking_voucher_quota_full(token_client, organizer, event): + qs = event.quotas.create(name='S', size=2) + qm = event.quotas.create(name='M', size=0) + v = event.vouchers.create(quota=qs, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'quota': qm.pk + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_change_item_of_blocking_voucher_without_quota_change(token_client, organizer, event, item, quota): + quota.size = 0 + quota.save() + ticket2 = event.items.create(name='Standard Ticket', default_price=23) + quota.items.add(ticket2) + v = event.vouchers.create(item=item, block_quota=True) + change_voucher( + token_client, organizer, event, v, + data={ + 'item': ticket2.pk + }, + ) + assert v.item == ticket2 + + +@pytest.mark.django_db +def test_change_code_to_duplicate(token_client, organizer, event, item, quota): + v1 = event.vouchers.create(quota=quota) + v2 = event.vouchers.create(quota=quota) + change_voucher( + token_client, organizer, event, v1, + data={ + 'code': v2.code + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_change_subevent_blocking_quota_free(token_client, organizer, event, item, quota, subevent): + quota.subevent = subevent + quota.save() + se2 = event.subevents.create(name="Bar", date_from=now()) + q2 = event.quotas.create(event=event, name='Tickets', size=5, subevent=se2) + q2.items.add(item) + + v = event.vouchers.create(item=item, block_quota=True, subevent=subevent) + change_voucher( + token_client, organizer, event, v, + data={ + 'subevent': se2.pk + }, + ) + assert v.subevent == se2 + + +@pytest.mark.django_db +def test_change_subevent_blocking_quota_full(token_client, organizer, event, item, quota, subevent): + quota.subevent = subevent + quota.save() + se2 = event.subevents.create(name="Bar", date_from=now()) + q2 = event.quotas.create(event=event, name='Tickets', size=0, subevent=se2) + q2.items.add(item) + + v = event.vouchers.create(item=item, block_quota=True, subevent=subevent) + change_voucher( + token_client, organizer, event, v, + data={ + 'subevent': se2.pk + }, + expected_failure=True + ) + + +@pytest.mark.django_db +def test_delete_voucher(token_client, organizer, event, quota): + v = event.vouchers.create(quota=quota) + resp = token_client.delete( + '/api/v1/organizers/{}/events/{}/vouchers/{}/'.format(organizer.slug, event.slug, v.pk), + ) + assert resp.status_code == 204 + assert not event.vouchers.filter(pk=v.id).exists() + + +@pytest.mark.django_db +def test_delete_voucher_redeemed(token_client, organizer, event, quota): + v = event.vouchers.create(quota=quota, redeemed=1) + resp = token_client.delete( + '/api/v1/organizers/{}/events/{}/vouchers/{}/'.format(organizer.slug, event.slug, v.pk), + ) + assert resp.status_code == 403 + assert event.vouchers.filter(pk=v.id).exists() + + +@pytest.mark.django_db +def test_redeemed_is_not_writable(token_client, organizer, event, item): + v = event.vouchers.create(item=item) + change_voucher( + token_client, organizer, event, v, + data={ + 'redeemed': 1, + }, + ) + assert v.redeemed == 0 diff --git a/src/tests/control/test_vouchers.py b/src/tests/control/test_vouchers.py index f7f0a2e692..3f1583fc85 100644 --- a/src/tests/control/test_vouchers.py +++ b/src/tests/control/test_vouchers.py @@ -144,6 +144,14 @@ class VoucherFormTest(SoupTest): assert v.code in doc.select(".alert-success")[0].text assert count_before + 1 == self.event.vouchers.count() + def test_create_voucher_for_addon_item(self): + c = self.event.categories.create(name="Foo", is_addon=True) + self.ticket.category = c + self.ticket.save() + self._create_voucher({ + 'itemvar': '%d' % self.ticket.pk + }, expected_failure=True) + def test_create_non_blocking_item_voucher(self): self._create_voucher({ 'itemvar': '%d' % self.ticket.pk @@ -255,6 +263,12 @@ class VoucherFormTest(SoupTest): v.refresh_from_db() assert v.block_quota + def test_change_voucher_reduce_max_usages(self): + v = self.event.vouchers.create(item=self.ticket, max_usages=5, redeemed=3) + self._change_voucher(v, { + 'max_usages': '2' + }, expected_failure=True) + def test_change_voucher_to_blocking_quota_full(self): self.quota_tickets.size = 0 self.quota_tickets.save()