diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 5fcab98c1..c34a9d679 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -8,7 +8,7 @@ def contextprocessor(request): Adds data to all template contexts """ url = resolve(request.path_info) - if url.namespace != 'control': + if not request.path.startswith('/control'): return {} ctx = { 'url_name': url.url_name, diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index e728a7ec4..512ba5a6f 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -24,9 +24,8 @@ class PermissionMiddleware: def process_request(self, request): url = resolve(request.path_info) - url_namespace = url.namespace url_name = url.url_name - if url_namespace != 'control' or url_name in self.EXCEPTIONS: + if not request.path.startswith('/control') or url_name in self.EXCEPTIONS: return if not request.user.is_authenticated(): # Taken from django/contrib/auth/decorators.py @@ -47,7 +46,7 @@ class PermissionMiddleware: request.user.events_cache = request.user.events.current.order_by( "organizer", "date_from").prefetch_related("organizer") - if 'event.' in url_name and 'event' in url.kwargs: + if 'event' in url.kwargs and 'organizer' in url.kwargs: try: request.event = Event.objects.current.filter( slug=url.kwargs['event'], diff --git a/src/pretix/control/static/pretixcontrol/less/main.less b/src/pretix/control/static/pretixcontrol/less/main.less index 4a8d618bd..1fea2841f 100644 --- a/src/pretix/control/static/pretixcontrol/less/main.less +++ b/src/pretix/control/static/pretixcontrol/less/main.less @@ -53,4 +53,15 @@ nav.navbar { } .container-fluid > .alert:first-child { margin-top: 20px; +} + +.flipped-scroll-wrapper { + overflow-y: auto; +} +.flipped-scroll-wrapper, .flipped-scroll-inner { + /* This nasty hack puts the scroll bar at the top, so the user really + notices that there is one */ + transform: rotateX(180deg); + -ms-transform: rotateX(180deg); /* IE 9 */ + -webkit-transform: rotateX(180deg); /* Safari and Chrome */ } \ No newline at end of file diff --git a/src/pretix/plugins/banktransfer/csvimport.py b/src/pretix/plugins/banktransfer/csvimport.py new file mode 100644 index 000000000..50794b3cb --- /dev/null +++ b/src/pretix/plugins/banktransfer/csvimport.py @@ -0,0 +1,85 @@ +import csv +import io + + +class HintMismatchError(Exception): + pass + + +def parse(data, hint): + result = [] + if 'cols' not in hint: + raise HintMismatchError('Invalid hint') + if len(data[0]) != hint['cols']: + raise HintMismatchError('Wrong column count') + for row in data: + resrow = {} + if None in row or len(row) == 0: + # Wrong column count + continue + if hint.get('payer') is not None: + resrow['payer'] = "\n".join([row[int(i)].strip() for i in hint.get('payer')]) + if hint.get('reference') is not None: + resrow['reference'] = "\n".join([row[int(i)].strip() for i in hint.get('reference')]) + if hint.get('amount') is not None: + resrow['amount'] = row[int(hint.get('amount'))].strip() + if hint.get('date') is not None: + resrow['date'] = row[int(hint.get('date'))].strip() + if len(resrow['amount']) == 0 or 'amount' not in resrow \ + or resrow['amount'][0] not in list("1234567890," "+- ") \ + or len(resrow['reference']) == 0: + # This is probably a headline or something other special. + continue + result.append(resrow) + return result + + +def get_rows_from_file(file): + data = file.read() + try: + import chardet + charset = chardet.detect(data)['encoding'] + except ImportError: + charset = file.charset + data = data.decode(charset or 'utf-8') + # Sniffing line by line is necessary as some banks like to include + # one-column garbage at the beginning of the file which breaks the sniffer. + # See also: http://bugs.python.org/issue2078 + last_e = None + dialect = None + for line in data.split("\n"): + line = line.strip() + if len(line) == 0: + continue + try: + dialect = csv.Sniffer().sniff(line, delimiters=";,.#:") + except Exception as e: + last_e = e + else: + last_e = None + break + if dialect is None: + raise last_e + reader = csv.reader(io.StringIO(data), dialect) + rows = [] + for row in reader: + if rows and len(row) > len(rows[0]): + # Some banks put metadata above the real data, things like + # a headline, the bank's name, the user's name, etc. + # In many cases, we can identify this because these rows + # have less columns than the rows containing the real data. + # Therefore, if the number of columns suddenly grows, we start + # over with parsing. + rows = [] + rows.append(row) + return rows + + +def new_hint(data): + return { + 'payer': data.getlist('payer') if 'payer' in data else None, + 'reference': data.getlist('reference') if 'date' in data else None, + 'date': int(data.get('date')) if 'date' in data else None, + 'amount': int(data.get('amount')) if 'amount' in data else None, + 'cols': int(data.get('cols')) if 'cols' in data else None + } diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index ece6a37b2..77c790bff 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -1,10 +1,29 @@ +from django.core.urlresolvers import reverse, resolve from django.dispatch import receiver +from django.utils.translation import ugettext_lazy as _ from pretix.base.signals import register_payment_providers from .payment import BankTransfer +from pretix.control.signals import nav_event @receiver(register_payment_providers) def register_payment_provider(sender, **kwargs): return BankTransfer + + +@receiver(nav_event) +def html_head_presale(sender, request=None, **kwargs): + url = resolve(request.path_info) + return [ + { + 'label': _('Import bank data'), + 'url': reverse('plugins:banktransfer.import', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': (url.namespace == 'plugins' and url.url_name == 'banktransfer.import'), + 'icon': 'upload', + } + ] diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html new file mode 100644 index 000000000..cab583f26 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html @@ -0,0 +1,68 @@ +{% extends "pretixplugins/banktransfer/import_base.html" %} +{% load i18n %} +{% block inner %} +

{% blocktrans trimmed %} + We've been unable to automatically determine how the columns in your file are aligned. Please help us + by selecting which column contain what kind of data. + {% endblocktrans %}

+
+ {% csrf_token %} + +
+ + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for row in rows %} + {% with forloop.counter0 as rowid %} + + + {% for col in row %} + + {% endfor %} + + {% endwith %} + {% endfor %} + + + + +
{% trans "Date" %} + +
{% trans "Amount" %} + +
{% trans "Reference" %} + +
{% trans "Payer" %} + +
{{ col }}
+
+
+{% endblock %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_base.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_base.html new file mode 100644 index 000000000..c0e6a5b42 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_base.html @@ -0,0 +1,8 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% block title %}{% trans "Import bank data" %}{% endblock %} +{% block content %} +

{% trans "Import bank data" %}

+ {% block inner %} + {% endblock %} +{% endblock %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_confirm.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_confirm.html new file mode 100644 index 000000000..c07f56116 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_confirm.html @@ -0,0 +1,55 @@ +{% extends "pretixplugins/banktransfer/import_base.html" %} +{% load i18n %} +{% block inner %} +

{% blocktrans trimmed %} + We detected the following payments. Please review them and click the 'Confirm' button below. + {% endblocktrans %}

+
+ {% csrf_token %} + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + + + + +
{% trans "Date" %}{% trans "Reference" %}{% trans "Amount" %}{% trans "Payer" %}{% trans "Order" %}{% trans "Status" %}
{% if row.ok %} + + + + + {% endif %}{{ row.date }}{{ row.reference }}{{ row.amount }}{{ row.payer }}{% if row.order %} + + {{ row.order.code }} + + {% endif %} + {{ row.message }}
+ +
+
+{% endblock %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html new file mode 100644 index 000000000..0ff0297f6 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html @@ -0,0 +1,25 @@ +{% extends "pretixplugins/banktransfer/import_base.html" %} +{% load i18n %} +{% block inner %} +

{% blocktrans trimmed %} + This page allows you to upload bank statement files to process incoming payments. + {% endblocktrans %}

+

{% blocktrans trimmed %} + Currently, only .csv files are supported. + {% endblocktrans %}

+
+
+

{% trans "Upload a new file" %}

+
+
+
+ {% csrf_token %} +
+ +
+ +
+
+{% endblock %} diff --git a/src/pretix/plugins/banktransfer/urls.py b/src/pretix/plugins/banktransfer/urls.py new file mode 100644 index 000000000..7b183113c --- /dev/null +++ b/src/pretix/plugins/banktransfer/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url + +from .views import * + + +urlpatterns = [ + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/import/', ImportView.as_view(), + name='banktransfer.import'), +] diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py new file mode 100644 index 000000000..0daaf4ba1 --- /dev/null +++ b/src/pretix/plugins/banktransfer/views.py @@ -0,0 +1,148 @@ +import csv +from decimal import Decimal +import json +import logging +import re +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.shortcuts import redirect, render +from django.utils.timezone import now +from django.views.generic import TemplateView +from pretix.base.models import Order +from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.plugins.banktransfer import csvimport +from django.utils.translation import ugettext_lazy as _ + + +logger = logging.getLogger('pretix.plugins.banktransfer') + + +class ImportView(EventPermissionRequiredMixin, TemplateView): + template_name = 'pretixplugins/banktransfer/import_form.html' + permission = 'can_change_orders' + + def post(self, *args, **kwargs): + if 'mark_paid' in self.request.POST: + orders = Order.objects.filter(event=self.request.event, + code__in=self.request.POST.getlist('mark_paid')) + for order in orders: + order.mark_paid(provider='banktransfer', info=json.dumps({ + 'reference': self.request.POST.get('reference_%s' % order.code), + 'date': self.request.POST.get('date_%s' % order.code), + 'payer': self.request.POST.get('payer_%s' % order.code), + 'import': now().isoformat(), + })) + + messages.success(self.request, _('The selected orders have been marked as paid.')) + return self.redirect_back() + return self.process_csv() + + def process_csv(self): + if 'file' in self.request.FILES: + # if file is csv file + try: + data = csvimport.get_rows_from_file(self.request.FILES['file']) + except csv.Error as e: # TODO: narrow down + logger.error('Import failed: ' + str(e)) + messages.error(self.request, _('I\'m sorry, but we were unable to import this CSV file. Please ' + 'contact support for help.')) + return self.redirect_back() + + if len(data) == 0: + messages.error(self.request, _('I\'m sorry, but we detected this file as empty. Please ' + 'contact support for help.')) + + if self.request.event.settings.get('banktransfer_csvhint') is not None: + hint = self.request.event.settings.get('banktransfer_csvhint', as_type=dict) + try: + parsed = csvimport.parse(data, hint) + except csvimport.HintMismatchError as e: # TODO: narrow down + logger.error('Import using stored hint failed: ' + str(e)) + else: + return self.confirm_view(parsed) + + return self.assign_view(data) + + elif 'amount' in self.request.POST: # CSV hint given + data = [] + for i in range(int(self.request.POST.get('rows'))): + data.append([ + self.request.POST.get('col[%d][%d]' % (i, j)) + for j in range(int(self.request.POST.get('cols'))) + ]) + if 'reference' not in self.request.POST: + messages.error(self.request, _('You need to select the column containing the payment reference.')) + return self.assign_view(data) + try: + hint = csvimport.new_hint(self.request.POST) + except Exception as e: + logger.error('Parsing hint failed: ' + str(e)) + messages.error(self.request, _('We were unable to process your input.')) + return self.assign_view(data) + try: + self.request.event.settings.set('banktransfer_csvhint', hint) + except Exception as e: # TODO: narrow down + logger.error('Import using stored hint failed: ' + str(e)) + pass + else: + parsed = csvimport.parse(data, hint) + return self.confirm_view(parsed) + return super().get(self.request) + + def confirm_view(self, parsed): + parsed = self.annotate_data(parsed) + return render(self.request, 'pretixplugins/banktransfer/import_confirm.html', { + 'rows': parsed + }) + + def assign_view(self, parsed): + return render(self.request, 'pretixplugins/banktransfer/import_assign.html', { + 'rows': parsed + }) + + def redirect_back(self): + return redirect(reverse('plugins:banktransfer.import', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + })) + + def annotate_data(self, data): + pattern = re.compile(self.request.event.slug.upper() + "([A-Z0-9]{5})") + amount_pattern = re.compile("[^0-9.-]") + for row in data: + row['ok'] = False + match = pattern.search(row['reference'].upper()) + if not match: + row['class'] = '' + row['message'] = _('No order code detected') + continue + + code = match.group(1) + try: + order = Order.objects.current.get(event=self.request.event, + code=code) + except Order.DoesNotExist: + row['class'] = 'error' + row['message'] = _('Unknown order code detected') + else: + row['order'] = order + if order.status == Order.STATUS_PENDING: + amount = Decimal(amount_pattern.sub("", row['amount'].replace(",", "."))) + if amount != order.total: + row['class'] = 'error' + row['message'] = _('Found wrong amount. Expected: %s' % str(order.total)) + else: + row['class'] = 'success' + row['message'] = _('Valid payment') + row['ok'] = True + elif order.status == Order.STATUS_CANCELLED: + row['class'] = 'error' + row['message'] = _('Order has been cancelled') + elif order.status == Order.STATUS_PAID: + # TODO: Do a plausibility check to tell duplicate payments from overlapping import files + row['class'] = '' + row['message'] = _('Order already has been paid') + elif order.status == Order.STATUS_REFUNDED: + row['class'] = 'warning' + row['message'] = _('Order has been refunded') + return data diff --git a/src/requirements.txt b/src/requirements.txt index 14e6108ef..56ee4f3cf 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -4,3 +4,4 @@ -r requirements/testing.txt -r requirements/paypal.txt -r requirements/stripe.txt +-r requirements/banktransfer.txt diff --git a/src/requirements/banktransfer.txt b/src/requirements/banktransfer.txt new file mode 100644 index 000000000..c60139b83 --- /dev/null +++ b/src/requirements/banktransfer.txt @@ -0,0 +1,2 @@ +chardet +