Add banktransfer API

This commit is contained in:
Raphael Michel
2017-06-27 11:54:27 +02:00
parent d446191cf4
commit 625e90518e
6 changed files with 427 additions and 12 deletions

View File

@@ -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

View File

@@ -11,3 +11,4 @@ If you want to **create** a plugin, please go to the
list
pretixdroid
banktransfer

View File

@@ -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)

View File

@@ -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', ''),

View File

@@ -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<organizer>[^/]+)/(?P<event>[^/]+)/banktransfer/action/',
views.EventActionView.as_view(), name='import.action'),
]
orga_router.register('bankimportjobs', BankImportJobViewSet)

View File

@@ -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