diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst new file mode 100644 index 0000000000..e785bd621d --- /dev/null +++ b/doc/api/resources/giftcards.rst @@ -0,0 +1,181 @@ +.. _`rest-giftcards`: + +Gift cards +========== + +Resource description +-------------------- + +The gift card resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the gift card +secret string Gift card code (can not be modified later) +value money (string) Current gift card value +currency string Currency of the value (can not be modified later) +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/giftcards/ + + Returns a list of all gift cards issued by a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/giftcards/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "13.37" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/giftcards/(id)/ + + Returns information on one gift card, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "13.37" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the gift card to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/giftcards/ + + Creates a new gift card + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/giftcards/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "13.37" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "13.37" + } + + :param organizer: The ``slug`` field of the organizer to create a gift card for + :statuscode 201: no error + :statuscode 400: The gift card could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/giftcards/(id)/ + + Update a gift card. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id``, ``secret``, and ``currency`` fields. Be careful when + modifying the ``value`` field to avoid race conditions. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "value": "14.00" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "13.37" + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the gift card to modify + :statuscode 200: no error + :statuscode 400: The gift card could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 0173f0cdfe..0ac952416a 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -21,6 +21,7 @@ Resources and endpoints vouchers checkinlists waitinglist + giftcards carts webhooks seatingplans diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index be61a0bfd8..a7980d5f4c 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -1,6 +1,11 @@ +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField -from pretix.base.models import Organizer, SeatingPlan +from pretix.base.models import GiftCard, Organizer, SeatingPlan from pretix.base.models.seating import SeatingPlanLayoutValidator @@ -18,3 +23,27 @@ class SeatingPlanSerializer(I18nAwareModelSerializer): class Meta: model = SeatingPlan fields = ('id', 'name', 'layout') + + +class GiftCardSerializer(I18nAwareModelSerializer): + value = serializers.DecimalField(max_digits=10, decimal_places=2) + + def validate(self, data): + data = super().validate(data) + s = data['secret'] + qs = GiftCard.objects.filter( + secret=s + ).filter( + Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"]) + ) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise ValidationError( + {'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')} + ) + return data + + class Meta: + model = GiftCard + fields = ('id', 'secret', 'issuance', 'value', 'currency') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 4397688d4a..917b2fe973 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -19,6 +19,7 @@ orga_router.register(r'events', event.EventViewSet) orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) +orga_router.register(r'giftcards', organizer.GiftCardViewSet) event_router = routers.DefaultRouter() event_router.register(r'subevents', event.SubEventViewSet) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index db6b5bdfb9..b0b9de0e0a 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -1,11 +1,12 @@ +from django.db import transaction from rest_framework import filters, viewsets -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import MethodNotAllowed, PermissionDenied from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import ( - OrganizerSerializer, SeatingPlanSerializer, + GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer, ) -from pretix.base.models import Organizer, SeatingPlan +from pretix.base.models import GiftCard, Organizer, SeatingPlan from pretix.helpers.dicts import merge_dicts @@ -81,3 +82,48 @@ class SeatingPlanViewSet(viewsets.ModelViewSet): data={'id': instance.pk} ) instance.delete() + + +class GiftCardViewSet(viewsets.ModelViewSet): + serializer_class = GiftCardSerializer + queryset = GiftCard.objects.none() + permission = 'can_manage_gift_cards' + write_permission = 'can_manage_gift_cards' + + def get_queryset(self): + return self.request.organizer.issued_gift_cards.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_create(self, serializer): + value = serializer.validated_data.pop('value') + inst = serializer.save(issuer=self.request.organizer) + inst.transactions.create(value=value) + self.request.organizer.log_action( + 'pretix.giftcards.transaction.manual', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + + @transaction.atomic() + def perform_update(self, serializer): + old_value = serializer.instance.value + value = serializer.validated_data.pop('value') + inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency) + diff = value - old_value + inst.transactions.create(value=diff) + self.request.organizer.log_action( + 'pretix.giftcards.transaction.manual', + user=self.request.user, + auth=self.request.auth, + data={'value': diff} + ) + return inst + + def perform_destroy(self, instance): + raise MethodNotAllowed("Gift cards cannot be deleted.") diff --git a/src/pretix/base/migrations/0137_auto_20190918_1820.py b/src/pretix/base/migrations/0137_auto_20190918_1820.py new file mode 100644 index 0000000000..56e5f6608e --- /dev/null +++ b/src/pretix/base/migrations/0137_auto_20190918_1820.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.1 on 2019-09-18 18:20 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.fields +import pretix.base.models.giftcards + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0136_auto_20190918_1537'), + ] + + operations = [ + migrations.AlterField( + model_name='giftcard', + name='secret', + field=models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, max_length=190), + ), + migrations.AlterUniqueTogether( + name='giftcard', + unique_together={('secret', 'issuer')}, + ), + ] diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index 83d6f36c8d..88839a62d6 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -48,7 +48,6 @@ class GiftCard(LoggedModel): secret = models.CharField( max_length=190, default=gen_giftcard_secret, - unique=True, db_index=True, verbose_name=_('Gift card code'), ) @@ -62,6 +61,9 @@ class GiftCard(LoggedModel): def value(self): return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00') + class Meta: + unique_together = (('secret', 'issuer'),) + class GiftCardTransaction(models.Model): card = models.ForeignKey( diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 9164fc3a33..503720f002 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -341,7 +341,7 @@ class GiftCardCreateForm(forms.ModelForm): self.organizer = kwargs.pop('organizer') super().__init__(*args, **kwargs) - def clean_secret(self): + def validate_secret(self): s = self.cleaned_data['secret'] if GiftCard.objects.filter( secret=s diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index d61b86351d..f0beff1512 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -70,6 +70,7 @@ def event3(organizer, meta_prop): def team(organizer): return Team.objects.create( organizer=organizer, + can_manage_gift_cards=True, can_change_items=True, can_create_events=True, can_change_event_settings=True, diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py new file mode 100644 index 0000000000..97cedb7119 --- /dev/null +++ b/src/tests/api/test_giftcards.py @@ -0,0 +1,103 @@ +import copy +from decimal import Decimal + +import pytest +from django_scopes import scopes_disabled + +from pretix.base.models import GiftCard + + +@pytest.fixture +def giftcard(organizer, event): + gc = organizer.issued_gift_cards.create(secret="ABCDEF", currency="EUR") + gc.transactions.create(value=Decimal('23.00')) + return gc + + +TEST_GC_RES = { + "id": 1, + "secret": "ABCDEF", + "value": "23.00", + "currency": "EUR" +} + + +@pytest.mark.django_db +def test_giftcard_list(token_client, organizer, event, giftcard): + res = dict(TEST_GC_RES) + res["id"] = giftcard.pk + res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z') + + resp = token_client.get('/api/v1/organizers/{}/giftcards/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_giftcard_detail(token_client, organizer, event, giftcard): + res = dict(TEST_GC_RES) + res["id"] = giftcard.pk + res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z') + resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +TEST_GIFTCARD_CREATE_PAYLOAD = { + "secret": "DEFABC", + "value": "12.00", + "currency": "EUR", +} + + +@pytest.mark.django_db +def test_giftcard_create(token_client, organizer, event): + resp = token_client.post( + '/api/v1/organizers/{}/giftcards/'.format(organizer.slug), + TEST_GIFTCARD_CREATE_PAYLOAD, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + gc = GiftCard.objects.get(pk=resp.data['id']) + assert gc.issuer == organizer + assert gc.value == Decimal('12.00') + + +@pytest.mark.django_db +def test_giftcard_duplicate_secert(token_client, organizer, event, giftcard): + res = copy.copy(TEST_GIFTCARD_CREATE_PAYLOAD) + res['secret'] = 'ABCDEF' + resp = token_client.post( + '/api/v1/organizers/{}/giftcards/'.format(organizer.slug), + res, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == {'secret': ['A gift card with the same secret already exists in your or an affiliated organizer account.']} + + +@pytest.mark.django_db +def test_giftcard_patch(token_client, organizer, event, giftcard): + resp = token_client.patch( + '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), + { + 'secret': 'foo', + 'value': '10.00', + 'currency': 'USD' + }, + format='json' + ) + assert resp.status_code == 200 + giftcard.refresh_from_db() + assert giftcard.value == Decimal('10.00') + assert giftcard.secret == "ABCDEF" + assert giftcard.currency == "EUR" + + +@pytest.mark.django_db +def test_giftcard_no_deletion(token_client, organizer, event, giftcard): + resp = token_client.delete( + '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), + ) + assert resp.status_code == 405 diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index aee4692843..b6c7d193e6 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -137,6 +137,11 @@ org_permission_sub_urls = [ ('put', 'can_change_organizer_settings', 'webhooks/1/', 404), ('patch', 'can_change_organizer_settings', 'webhooks/1/', 404), ('delete', 'can_change_organizer_settings', 'webhooks/1/', 404), + ('get', 'can_manage_gift_cards', 'giftcards/', 200), + ('post', 'can_manage_gift_cards', 'giftcards/', 400), + ('get', 'can_manage_gift_cards', 'giftcards/1/', 404), + ('put', 'can_manage_gift_cards', 'giftcards/1/', 404), + ('patch', 'can_manage_gift_cards', 'giftcards/1/', 404), ] diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 48542f4279..aa32abd769 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -141,6 +141,9 @@ organizer_urls = [ 'organizer/abc/webhook/add', 'organizer/abc/webhook/1/edit', 'organizer/abc/webhook/1/logs', + 'organizer/abc/giftcards', + 'organizer/abc/giftcard/add', + 'organizer/abc/giftcard/1/', ] @@ -390,10 +393,9 @@ organizer_permission_urls = [ ("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404), - ("can_change_organizer_settings", "organizer/dummy/webhooks", 200), - ("can_change_organizer_settings", "organizer/dummy/webhook/add", 200), - ("can_change_organizer_settings", "organizer/dummy/webhook/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/webhook/1/logs", 404), + ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), + ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), + ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), ] diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 247476c215..3b1a92fb92 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -102,6 +102,7 @@ def logged_in_client(client, event): ('/control/organizer/{orga}/teams', 200), ('/control/organizer/{orga}/devices', 200), ('/control/organizer/{orga}/webhooks', 200), + ('/control/organizer/{orga}/giftcards', 200), ('/control/events/', 200), ('/control/events/add', 200),