From 625e90518e891ee9d470143fd75868d9ebc8fd37 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 27 Jun 2017 11:54:27 +0200 Subject: [PATCH] Add banktransfer API --- doc/plugins/banktransfer.rst | 201 +++++++++++++++++++++ doc/plugins/index.rst | 1 + src/pretix/plugins/banktransfer/api.py | 86 +++++++++ src/pretix/plugins/banktransfer/tasks.py | 25 +-- src/pretix/plugins/banktransfer/urls.py | 5 + src/tests/plugins/banktransfer/test_api.py | 121 +++++++++++++ 6 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 doc/plugins/banktransfer.rst create mode 100644 src/pretix/plugins/banktransfer/api.py create mode 100644 src/tests/plugins/banktransfer/test_api.py diff --git a/doc/plugins/banktransfer.rst b/doc/plugins/banktransfer.rst new file mode 100644 index 0000000000..15e0cbc2fe --- /dev/null +++ b/doc/plugins/banktransfer.rst @@ -0,0 +1,201 @@ +Bank transfer HTTP API +====================== + +The banktransfer plugin provides a HTTP API that `pretix-banktool`_ uses to send bank +transactions to the pretix server. This API is integrated with the regular :ref:`rest-api` +and therefore follows the conventions listed there. + +Bank import job resource +^^^^^^^^^^^^^^^^^^^^^^^^ + +Resource description +-------------------- + +The bank import job resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal job ID +event string Slug of the event this job was uploaded for or ``null`` +created datetime Job creation time +state string Job state, one of ``pending``, ``running``, + ``error`` or ``completed`` +transactions list of objects Transactions included in this job (will only appear + after the job has started processing). +├ state string Transaction state, one of ``imported``, ``nomatch``, + ``invalid``, ``error``, ``valid``, ``discarded``, + ``already`` (already paid) +├ message string Error message (if any) +├ checksum string Checksum computed from payer, reference, amount and + date +├ payer string Payment source +├ reference string Payment reference +├ amount string Payment amount +├ date string Payment date (in **user-inputted** format) +├ order string Associated order code (or ``null``) +└ comment string Internal comment +===================================== ========================== ======================================================= + +Note that the ``payer`` and ``reference`` fields are set to empty as soon as the payment is matched to an order or +discarded to avoid storing sensitive data when not necessary. The ``checksum`` persists to implement deduplication. + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/bankimportjobs/ + + Returns a list of all bank import jobs within a given organizer the authenticated user/token has access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/bankimportjobs/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "state": "completed", + "created": "2017-06-27T08:00:29Z", + "event": "sampleconf", + "transactions": [ + { + "amount": "57.00", + "comment": "", + "date": "26.06.2017", + "payer": "John Doe", + "order": null, + "checksum": "5de03a601644dfa63420dacfd285565f8375a8f2", + "reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…", + "state": "nomatch", + "message": "" + } + ] + } + ] + } + + :query page: The page number in case of a multi-page result set, default is 1 + :query event: Return only jobs for the event with the given slug + :query state: Return only jobs with the given state + :param organizer: The ``slug`` field of a valid organizer + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. + +.. http:get:: /api/v1/organizers/(organizer)/bankimportjobs/(id)/ + + Returns information on one job, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/bankimportjobs/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: text/javascript + + { + "id": 1, + "state": "completed", + "created": "2017-06-27T08:00:29Z", + "event": "sampleconf", + "transactions": [ + { + "amount": "57.00", + "comment": "", + "date": "26.06.2017", + "payer": "John Doe", + "order": null, + "checksum": "5de03a601644dfa63420dacfd285565f8375a8f2", + "reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…", + "state": "nomatch", + "message": "" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. + +.. http:post:: /api/v1/organizers/(organizer)/bankimportjobs/ + + Upload a new job and execute it. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/bankimportjobs/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "event": "sampleconf", + "transactions": [ + { + "payer": "Foo", + "reference": "SAMPLECONF-173AS", + "amount": "23.00", + "date": "2017-06-26" + } + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "state": "pending", + "created": "2017-06-27T08:00:29Z", + "event": "sampleconf", + "transactions": [] + } + + .. note:: Depending on the server configuration, the job might be executed immediately, leading to a longer API + response time but a response with state ``completed`` or ``error``, or the job might be put into a + background queue, leading to an immediate response of state ``pending`` with an empty list of + transactions. + + :param organizer: The ``slug`` field of a valid organizer + :statuscode 201: no error + :statuscode 400: Invalid input + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to perform this action. + +.. _pretix-banktool: https://github.com/pretix/pretix-banktool diff --git a/doc/plugins/index.rst b/doc/plugins/index.rst index 832ca727e8..eb5a4b8058 100644 --- a/doc/plugins/index.rst +++ b/doc/plugins/index.rst @@ -11,3 +11,4 @@ If you want to **create** a plugin, please go to the list pretixdroid + banktransfer diff --git a/src/pretix/plugins/banktransfer/api.py b/src/pretix/plugins/banktransfer/api.py new file mode 100644 index 0000000000..ac7991c4e3 --- /dev/null +++ b/src/pretix/plugins/banktransfer/api.py @@ -0,0 +1,86 @@ +import django_filters +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import serializers, status, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.mixins import CreateModelMixin +from rest_framework.response import Response + +from pretix.base.models.organizer import TeamAPIToken + +from .models import BankImportJob, BankTransaction +from .tasks import process_banktransfers + + +class BankTransactionSerializer(serializers.ModelSerializer): + order = serializers.SlugRelatedField(slug_field='code', read_only=True) + message = serializers.CharField(read_only=True) + state = serializers.CharField(read_only=True) + checksum = serializers.CharField(read_only=True) + + class Meta: + model = BankTransaction + fields = ('state', 'message', 'checksum', 'payer', 'reference', 'amount', 'date', 'order', + 'comment') + + +class BankImportJobSerializer(serializers.ModelSerializer): + event = serializers.SlugRelatedField(slug_field='slug', read_only=True, allow_null=True) + transactions = BankTransactionSerializer(many=True, read_only=False) + state = serializers.CharField(read_only=True) + partial = False + + class Meta: + model = BankImportJob + fields = ('id', 'event', 'created', 'state', 'transactions') + + def __init__(self, *args, **kwargs): + self.organizer = kwargs.pop('organizer') + self.fields['event'].read_only = False + self.fields['event'].queryset = self.organizer.events.all() + super().__init__(*args, **kwargs) + + def create(self, validated_data): + trans_data = validated_data.pop('transactions') + job = BankImportJob.objects.create(organizer=self.organizer, **validated_data) + job._data = trans_data + return job + + +class JobFilter(FilterSet): + event = django_filters.CharFilter(name='event', lookup_expr='slug') + + class Meta: + model = BankImportJob + fields = ['state', 'event'] + + +class BankImportJobViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = BankImportJobSerializer + queryset = BankImportJob.objects.none() + filter_backends = (DjangoFilterBackend,) + filter_class = JobFilter + permission = 'can_view_orders' + + def get_queryset(self): + return BankImportJob.objects.filter(organizer=self.request.organizer) + + def perform_create(self, serializer): + return serializer.save() + + def create(self, request, *args, **kwargs): + perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user) + if not perm_holder.has_organizer_permission(request.organizer, 'can_change_orders'): + raise PermissionDenied('Invalid set of permissions') + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + job = self.perform_create(serializer) + process_banktransfers.apply_async(kwargs={ + 'job': job.pk, + 'data': job._data + }) + job.refresh_from_db() + return Response(self.get_serializer(instance=job).data, status=status.HTTP_201_CREATED) + + def get_serializer(self, *args, **kwargs): + kwargs['organizer'] = self.request.organizer + return super().get_serializer(*args, **kwargs) diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 0211271433..b949b27dae 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -89,18 +89,19 @@ def _get_unknown_transactions(job: BankImportJob, data: list, event: Event=None, transactions = [] for row in data: amount = row['amount'] - if ',' in amount and '.' in amount: - # Handle thousand-seperator , or . - if amount.find(',') < amount.find('.'): - amount = amount.replace(',', '') - else: - amount = amount.replace('.', '') - amount = amount_pattern.sub("", amount.replace(',', '.')) - try: - amount = Decimal(amount) - except: - logger.exception('Could not parse amount of transaction: {}'.format(amount)) - amount = Decimal("0.00") + if not isinstance(amount, Decimal): + if ',' in amount and '.' in amount: + # Handle thousand-seperator , or . + if amount.find(',') < amount.find('.'): + amount = amount.replace(',', '') + else: + amount = amount.replace('.', '') + amount = amount_pattern.sub("", amount.replace(',', '.')) + try: + amount = Decimal(amount) + except: + logger.exception('Could not parse amount of transaction: {}'.format(amount)) + amount = Decimal("0.00") trans = BankTransaction(event=event, organizer=organizer, import_job=job, payer=row.get('payer', ''), diff --git a/src/pretix/plugins/banktransfer/urls.py b/src/pretix/plugins/banktransfer/urls.py index 55c15b8119..de8dc3533a 100644 --- a/src/pretix/plugins/banktransfer/urls.py +++ b/src/pretix/plugins/banktransfer/urls.py @@ -1,5 +1,8 @@ from django.conf.urls import url +from pretix.api.urls import orga_router +from pretix.plugins.banktransfer.api import BankImportJobViewSet + from . import views urlpatterns = [ @@ -19,3 +22,5 @@ urlpatterns = [ url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/action/', views.EventActionView.as_view(), name='import.action'), ] + +orga_router.register('bankimportjobs', BankImportJobViewSet) diff --git a/src/tests/plugins/banktransfer/test_api.py b/src/tests/plugins/banktransfer/test_api.py new file mode 100644 index 0000000000..fd81ae2a9f --- /dev/null +++ b/src/tests/plugins/banktransfer/test_api.py @@ -0,0 +1,121 @@ +import copy +import json +from datetime import timedelta + +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + Event, Item, Order, OrderPosition, Organizer, Quota, Team, User, +) +from pretix.plugins.banktransfer.models import BankImportJob, BankTransaction + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), plugins='pretix.plugins.banktransfer' + ) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t.members.add(user) + t.limit_events.add(event) + o1 = Order.objects.create( + code='1Z3AS', event=event, + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=23, payment_provider='banktransfer' + ) + o2 = Order.objects.create( + code='6789Z', event=event, + status=Order.STATUS_CANCELED, + datetime=now(), expires=now() + timedelta(days=10), + total=23, payment_provider='banktransfer' + ) + quota = Quota.objects.create(name="Test", size=2, event=event) + item1 = Item.objects.create(event=event, name="Ticket", default_price=23) + quota.items.add(item1) + OrderPosition.objects.create(order=o1, item=item1, variation=None, price=23) + return event, user, o1, o2 + + +RES_JOB = { + 'event': 'dummy', + 'id': 1, + 'transactions': [ + {'comment': '', + 'message': '', + 'payer': 'Foo', + 'reference': '', + 'checksum': '', + 'amount': '0.00', + 'date': 'unknown', + 'state': 'error', + 'order': None + } + ], + 'created': '2017-06-27T09:13:35.785251Z', + 'state': 'pending' +} + + +@pytest.mark.django_db +def test_api_list(env, client): + job = BankImportJob.objects.create(event=env[0], organizer=env[0].organizer) + BankTransaction.objects.create(event=env[0], import_job=job, payer='Foo', + state=BankTransaction.STATE_ERROR, + amount=0, date='unknown') + res = copy.copy(RES_JOB) + res['id'] = job.pk + res['created'] = job.created.isoformat().replace('+00:00', 'Z') + client.login(email='dummy@dummy.dummy', password='dummy') + r = json.loads( + client.get('/api/v1/organizers/{}/bankimportjobs/'.format(env[0].organizer.slug)).content.decode('utf-8') + ) + assert r['results'] == [res] + + +@pytest.mark.django_db +def test_api_detail(env, client): + job = BankImportJob.objects.create(event=env[0], organizer=env[0].organizer) + BankTransaction.objects.create(event=env[0], import_job=job, payer='Foo', + state=BankTransaction.STATE_ERROR, + amount=0, date='unknown') + res = copy.copy(RES_JOB) + res['id'] = job.pk + res['created'] = job.created.isoformat().replace('+00:00', 'Z') + client.login(email='dummy@dummy.dummy', password='dummy') + r = json.loads( + client.get( + '/api/v1/organizers/{}/bankimportjobs/{}/'.format(env[0].organizer.slug, job.pk) + ).content.decode('utf-8') + ) + assert r == res + + +@pytest.mark.django_db(transaction=True) +def test_api_create(env, client): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.post( + '/api/v1/organizers/{}/bankimportjobs/'.format(env[0].organizer.slug), json.dumps({ + 'event': 'dummy', + 'transactions': [ + { + 'payer': 'Foo', + 'reference': 'DUMMY-1Z3AS', + 'amount': '23.00', + 'date': 'yesterday' # test bogus date format + } + ] + }), content_type="application/json" + ) + assert r.status_code == 201 + rdata = json.loads(r.content.decode('utf-8')) + # This is only because we don't run celery in tests, otherwise it wouldn't be completed yet. + assert rdata['state'] == 'completed' + assert len(rdata['transactions']) == 1 + assert rdata['transactions'][0]['checksum'] + env[2].refresh_from_db() + assert env[2].status == Order.STATUS_PAID