mirror of
https://github.com/pretix/pretix.git
synced 2026-08-29 13:44:40 +00:00
Add banktransfer API
This commit is contained in:
@@ -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
|
list
|
||||||
pretixdroid
|
pretixdroid
|
||||||
|
banktransfer
|
||||||
|
|||||||
@@ -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 = []
|
transactions = []
|
||||||
for row in data:
|
for row in data:
|
||||||
amount = row['amount']
|
amount = row['amount']
|
||||||
if ',' in amount and '.' in amount:
|
if not isinstance(amount, Decimal):
|
||||||
# Handle thousand-seperator , or .
|
if ',' in amount and '.' in amount:
|
||||||
if amount.find(',') < amount.find('.'):
|
# Handle thousand-seperator , or .
|
||||||
amount = amount.replace(',', '')
|
if amount.find(',') < amount.find('.'):
|
||||||
else:
|
amount = amount.replace(',', '')
|
||||||
amount = amount.replace('.', '')
|
else:
|
||||||
amount = amount_pattern.sub("", amount.replace(',', '.'))
|
amount = amount.replace('.', '')
|
||||||
try:
|
amount = amount_pattern.sub("", amount.replace(',', '.'))
|
||||||
amount = Decimal(amount)
|
try:
|
||||||
except:
|
amount = Decimal(amount)
|
||||||
logger.exception('Could not parse amount of transaction: {}'.format(amount))
|
except:
|
||||||
amount = Decimal("0.00")
|
logger.exception('Could not parse amount of transaction: {}'.format(amount))
|
||||||
|
amount = Decimal("0.00")
|
||||||
|
|
||||||
trans = BankTransaction(event=event, organizer=organizer, import_job=job,
|
trans = BankTransaction(event=event, organizer=organizer, import_job=job,
|
||||||
payer=row.get('payer', ''),
|
payer=row.get('payer', ''),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from pretix.api.urls import orga_router
|
||||||
|
from pretix.plugins.banktransfer.api import BankImportJobViewSet
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -19,3 +22,5 @@ urlpatterns = [
|
|||||||
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/banktransfer/action/',
|
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/banktransfer/action/',
|
||||||
views.EventActionView.as_view(), name='import.action'),
|
views.EventActionView.as_view(), name='import.action'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
orga_router.register('bankimportjobs', BankImportJobViewSet)
|
||||||
|
|||||||
@@ -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