forked from CGM_Public/pretix_original
Add banktransfer API
This commit is contained in:
201
doc/plugins/banktransfer.rst
Normal file
201
doc/plugins/banktransfer.rst
Normal 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
|
||||
@@ -11,3 +11,4 @@ If you want to **create** a plugin, please go to the
|
||||
|
||||
list
|
||||
pretixdroid
|
||||
banktransfer
|
||||
|
||||
86
src/pretix/plugins/banktransfer/api.py
Normal file
86
src/pretix/plugins/banktransfer/api.py
Normal 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)
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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)
|
||||
|
||||
121
src/tests/plugins/banktransfer/test_api.py
Normal file
121
src/tests/plugins/banktransfer/test_api.py
Normal 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
|
||||
Reference in New Issue
Block a user