diff --git a/src/pretix/base/views/async.py b/src/pretix/base/views/async.py new file mode 100644 index 0000000000..9be9f684f1 --- /dev/null +++ b/src/pretix/base/views/async.py @@ -0,0 +1,134 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.utils.translation import ugettext as _ + +logger = logging.getLogger('pretix.base.async') + + +class AsyncAction: + task = None + success_url = None + error_url = None + + def do(self, *args): + if settings.HAS_CELERY: + from pretix.celery import app + + if hasattr(self.task, 'task') and isinstance(self.task.task, app.Task): + return self._do_celery(args) + else: + raise TypeError('Method has no task attached') + else: + return self._do_sync(args) + + def get_success_url(self, value): + return self.success_url + + def get_error_url(self): + return self.error_url + + def get_check_url(self, task_id, ajax): + return self.request.path + '?async_id=%s' % task_id + ('&ajax=1' if ajax else '') + + def get(self, request, *args, **kwargs): + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return self.http_method_not_allowed(request) + + def _return_celery_result(self, res, timeout=.5): + import celery.exceptions + + if not res.ready(): + try: + res.get(timeout=timeout) + except celery.exceptions.TimeoutError: + pass + ready = res.ready() + data = { + 'async_id': res.id, + 'ready': ready + } + if ready: + if res.successful() and not isinstance(res.info, Exception): + smes = self.get_success_message(res.info) + if smes: + messages.success(self.request, smes) + # TODO: Do not store message if the ajax client stats that it will not redirect + # but handle the mssage itself + data.update({ + 'redirect': self.get_success_url(res.info), + 'message': str(self.get_success_message(res.info)) + }) + else: + messages.error(self.request, self.get_error_message(res.info)) + # TODO: Do not store message if the ajax client stats that it will not redirect + # but handle the mssage itself + data.update({ + 'redirect': self.get_error_url(), + 'message': str(self.get_error_message(res.info)) + }) + return data + + def get_result(self, request): + from celery.result import AsyncResult + + res = AsyncResult(request.GET.get('async_id')) + if 'ajax' in self.request.GET: + return JsonResponse(self._return_celery_result(res, timeout=0.25)) + else: + if res.ready(): + if res.successful(): + return self.success(res.info) + else: + return self.error(res.info) + return render(request, 'pretixpresale/waiting.html') + + def _do_celery(self, args): + res = self.task.task.apply_async(args=args) + if 'ajax' in self.request.GET or 'ajax' in self.request.POST: + data = self._return_celery_result(res) + data['check_url'] = self.get_check_url(res.id, True) + return JsonResponse(data) + else: + return redirect(self.get_check_url(res.id, False)) + + def _do_sync(self, args): + try: + rs = getattr(self.__class__, 'task')(*args) + return self.success(rs) + except Exception as e: + logger.exception('Error while executing task synchronously') + return self.error(e) + + def success(self, value): + smes = self.get_success_message(value) + if smes: + messages.success(self.request, smes) + if "ajax" in self.request.POST or "ajax" in self.request.GET: + return JsonResponse({ + 'ready': True, + 'redirect': self.get_success_url(value), + 'message': str(self.get_success_message(value)) + }) + return redirect(self.get_success_url(value)) + + def error(self, exception): + messages.error(self.request, self.get_error_message(exception)) + if "ajax" in self.request.POST or "ajax" in self.request.GET: + return JsonResponse({ + 'ready': True, + 'redirect': self.get_error_url(), + 'message': str(self.get_error_message(exception)) + }) + return redirect(self.get_error_url()) + + def get_error_message(self, exception): + logger.error('Unexpected exception: %r' % exception) + return _('An unexpected error has occured.') + + def get_success_message(self, value): + return _('The task has been completed.') diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 21d1fb97e3..42ba451fd1 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -13,6 +13,7 @@ {% compress js %} + @@ -132,5 +133,14 @@ +
+
+
+ +

{% trans "We are processing your request…" %}

+

+ {% trans "If this takes longer than a few minutes, please contact us." %} +

+
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 13a9ddf514..9318b6d5ca 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -42,8 +42,8 @@ from pretix.multidomain.urlreverse import build_absolute_uri class OrderList(EventPermissionRequiredMixin, ListView): model = Order context_object_name = 'orders' - template_name = 'pretixcontrol/orders/index.html' paginate_by = 30 + template_name = 'pretixcontrol/orders/index.html' permission = 'can_view_orders' def get_queryset(self): diff --git a/src/pretix/plugins/banktransfer/__init__.py b/src/pretix/plugins/banktransfer/__init__.py index 5ef3187d41..abb40d57b2 100644 --- a/src/pretix/plugins/banktransfer/__init__.py +++ b/src/pretix/plugins/banktransfer/__init__.py @@ -20,6 +20,7 @@ class BankTransferApp(AppConfig): def ready(self): from . import signals # NOQA + from . import tasks # NOQA @cached_property def compatibility_warnings(self): @@ -28,10 +29,6 @@ class BankTransferApp(AppConfig): import chardet # NOQA except ImportError: errs.append(_("Install the python package 'chardet' for better CSV import capabilities.")) - try: - import defusedxml # NOQA - except ImportError: - errs.append(_("Please install the python package 'defusedxml' for security reasons.")) return errs diff --git a/src/pretix/plugins/banktransfer/migrations/0001_initial.py b/src/pretix/plugins/banktransfer/migrations/0001_initial.py new file mode 100644 index 0000000000..ae73e69b44 --- /dev/null +++ b/src/pretix/plugins/banktransfer/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2016-09-07 09:35 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('pretixbase', '0036_auto_20160902_0755'), + ] + + operations = [ + migrations.CreateModel( + name='BankImportJob', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('state', models.CharField(choices=[('running', 'pending'), ('running', 'running'), ('error', 'error'), ('completed', 'completed')], default='running', max_length=32)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), + ], + ), + migrations.CreateModel( + name='BankTransaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.CharField(choices=[('imported', 'imported, unchecked'), ('nomatch', 'no match'), ('invalid', 'not valid'), ('error', 'error'), ('valid', 'valid'), ('already', 'valid, already paid'), ('discarded', 'manually discarded')], default='imported', max_length=32)), + ('message', models.TextField()), + ('checksum', models.CharField(db_index=True, max_length=190)), + ('payer', models.TextField(blank=True)), + ('reference', models.TextField(blank=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('date', models.DateField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), + ('import_job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='banktransfer.BankImportJob')), + ('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')), + ], + ), + migrations.AlterUniqueTogether( + name='banktransaction', + unique_together=set([('event', 'checksum')]), + ), + ] diff --git a/src/pretix/plugins/banktransfer/migrations/__init__.py b/src/pretix/plugins/banktransfer/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/plugins/banktransfer/models.py b/src/pretix/plugins/banktransfer/models.py new file mode 100644 index 0000000000..410cd6ac83 --- /dev/null +++ b/src/pretix/plugins/banktransfer/models.py @@ -0,0 +1,68 @@ +import hashlib +import re + +from django.db import models + + +class BankImportJob(models.Model): + STATE_PENDING = 'pending' + STATE_RUNNING = 'running' + STATE_ERROR = 'error' + STATE_COMPLETED = 'completed' + STATES = ( + (STATE_PENDING, 'pending'), + (STATE_RUNNING, 'running'), + (STATE_ERROR, 'error'), + (STATE_COMPLETED, 'completed'), + ) + + event = models.ForeignKey('pretixbase.Event') + created = models.DateTimeField(auto_now_add=True) + state = models.CharField(max_length=32, choices=STATES, default=STATE_PENDING) + + +class BankTransaction(models.Model): + STATE_UNCHECKED = 'imported' + STATE_NOMATCH = 'nomatch' + STATE_INVALID = 'invalid' + STATE_ERROR = 'error' + STATE_VALID = 'valid' + STATE_DISCARDED = 'discarded' + STATE_DUPLICATE = 'already' + + STATES = ( + (STATE_UNCHECKED, 'imported, unchecked'), + (STATE_NOMATCH, 'no match'), + (STATE_INVALID, 'not valid'), + (STATE_ERROR, 'error'), + (STATE_VALID, 'valid'), + (STATE_DUPLICATE, 'valid, already paid'), + (STATE_DISCARDED, 'manually discarded'), + ) + + event = models.ForeignKey('pretixbase.Event') + import_job = models.ForeignKey('BankImportJob', related_name='transactions') + state = models.CharField(max_length=32, choices=STATES, default=STATE_UNCHECKED) + message = models.TextField() + checksum = models.CharField(max_length=190, db_index=True) + payer = models.TextField(blank=True) + reference = models.TextField(blank=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + date = models.DateField() + order = models.ForeignKey('pretixbase.Order', null=True, blank=True) + + def calculate_checksum(self): + clean = re.compile('[^a-zA-Z0-9.-]') + hasher = hashlib.sha1() + hasher.update(clean.sub('', self.payer.lower()).encode('utf-8')) + hasher.update(clean.sub('', self.reference.lower()).encode('utf-8')) + hasher.update(clean.sub('', str(self.amount).lower()).encode('utf-8')) + hasher.update(clean.sub('', str(self.date).lower()).encode('utf-8')) + return str(hasher.hexdigest()) + + def shred_private_data(self): + self.payer = "" + self.reference = "" + + class Meta: + unique_together = ('event', 'checksum') diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index b3bef1b537..b31fc531e4 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -1,9 +1,11 @@ from django.core.urlresolvers import resolve, reverse from django.dispatch import receiver +from django.template import Context +from django.template.loader import get_template from django.utils.translation import ugettext_lazy as _ from pretix.base.signals import register_payment_providers -from pretix.control.signals import nav_event +from pretix.control.signals import html_head, nav_event from .payment import BankTransfer @@ -29,3 +31,14 @@ def control_nav_import(sender, request=None, **kwargs): 'icon': 'upload', } ] + + +@receiver(html_head, dispatch_uid="banktransfer_html_head") +def html_head_presale(sender, request=None, **kwargs): + url = resolve(request.path_info) + if url.namespace == 'plugins:banktransfer': + template = get_template('pretixplugins/banktransfer/control_head.html') + ctx = Context({}) + return template.render(ctx) + else: + return "" diff --git a/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.css b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.css new file mode 100644 index 0000000000..a7edbfb206 --- /dev/null +++ b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.css @@ -0,0 +1,10 @@ +table.transaction-list td.actions { + max-width: 100px; +} +table.transaction-list td.actions .input-group { + display: inline-block; + width: 110px; +} +table.transaction-list td.actions .input-group button { + line-height: 1.43; +} diff --git a/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js new file mode 100644 index 0000000000..5326552953 --- /dev/null +++ b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js @@ -0,0 +1,132 @@ +/*global $, gettext*/ +var bankimport_transactionlist = { + + _btn_click: function (e) { + console.log(e.delegateTarget); + var trans_id = parseInt($(e.delegateTarget).attr("name").split("_")[1]); + var value = $(e.delegateTarget).val(); + if (value === "discard") { + bankimport_transactionlist.discard(trans_id); + } else if (value === "accept") { + bankimport_transactionlist.accept(trans_id); + } else if (value === "retry") { + bankimport_transactionlist.retry(trans_id); + } else if (value === "assign") { + bankimport_transactionlist.assign(trans_id); + } + return false; + }, + + _action: function (id, action, success) { + $("tr[data-id=" + id + "] button").prop("disabled", true); + var data = { + "csrfmiddlewaretoken": $("[name=csrfmiddlewaretoken]").val() + }; + data["action_" + id] = action; + $.ajax({ + "method": "POST", + "url": $(".transaction-list").attr("data-url"), + "data": data, + "dataType": "json", + "success": function (data) { + if (data.status == "ok") { + $("tr[data-id=" + id + "]").removeClass("has-error"); + success(); + } else { + $("tr[data-id=" + id + "] button").prop("disabled", false); + $("tr[data-id=" + id + "] .help-block").remove(); + $("tr[data-id=" + id + "]").addClass("has-error"); + $("

").addClass("help-block").text(data.message).appendTo($("tr[data-id=" + id + "] td.actions")); + } + } + }); + }, + + discard: function (id) { + bankimport_transactionlist._action(id, "discard", function () { + $("tr[data-id=" + id + "] td").remove(); + }); + }, + + retry: function (id) { + bankimport_transactionlist._action(id, "retry", function () { + $("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid")); + }); + }, + + accept: function (id) { + bankimport_transactionlist._action(id, "accept", function () { + $("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid")); + }); + }, + + assign: function (id) { + bankimport_transactionlist._action(id, "assign:" + $("tr[data-id=" + id + "] input.form-control:not(.tt-hint)").val(), function () { + $("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid")); + }); + }, + + typeahead_source: function () { + return new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: $(".transaction-list").attr("data-url"), + prepare: function (query, settings) { + settings.url = settings.url + '?query=' + encodeURIComponent(query); + return settings; + }, + transform: function (object) { + var results = object.results; + var suggs = []; + var reslen = results.length; + for (var i = 0; i < reslen; i++) { + suggs.push(results[i]); + } + return suggs; + } + } + }); + }, + + init: function () { + if ($(".transaction-list").length) { + $(".transaction-list button").click(bankimport_transactionlist._btn_click); + $(".transaction-list .form-control").typeahead(null, { + minLength: 2, + name: 'order-dataset', + source: bankimport_transactionlist.typeahead_source(), + display: function (obj) { + return obj.code; + }, + templates: { + suggestion: function (obj) { + return '

' + obj.code + ' (' + obj.total + ', ' + obj.status + ')
'; + } + } + }).keypress(function (e) { + if (e.keyCode === 13) { + $(this).parent().parent().find("button[value=assign]").click(); + } + }); + } + + if ($("[data-job-waiting]").length) { + window.setInterval(bankimport_transactionlist.check_state, 750); + } + }, + + check_state: function () { + $.getJSON(location.pathname + '?ajax=1', function (data) { + if (data.state == 'running' || data.state == 'pending') { + window.setInterval(check_state, 750); + } else { + location.reload(); + } + }); + } +}; + +$(function () { + bankimport_transactionlist.init(); +}); diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py new file mode 100644 index 0000000000..96e530255c --- /dev/null +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -0,0 +1,130 @@ +import json +import logging +import re +from decimal import Decimal + +import dateutil.parser +from django.conf import settings +from django.db import transaction +from django.utils.translation import ugettext_noop + +from pretix.base.i18n import language +from pretix.base.models import Event, Order, Quota +from pretix.base.services.mail import SendMailException +from pretix.base.services.orders import mark_order_paid + +from .models import BankImportJob, BankTransaction + +logger = logging.getLogger(__name__) + + +def _handle_transaction(event: Event, trans: BankTransaction, code: str): + try: + trans.order = event.orders.get(code=code) + except Order.DoesNotExist: + normalized_code = Order.normalize_code(code) + try: + trans.order = event.orders.get(code=normalized_code) + except Order.DoesNotExist: + trans.state = BankTransaction.STATE_NOMATCH + trans.save() + return + + if trans.order.status == Order.STATUS_PAID: + trans.state = BankTransaction.STATE_DUPLICATE + elif trans.order.status == Order.STATUS_REFUNDED: + trans.state = BankTransaction.STATE_ERROR + trans.message = ugettext_noop('The order has already been refunded.') + elif trans.order.status == Order.STATUS_CANCELLED: + trans.state = BankTransaction.STATE_ERROR + trans.message = ugettext_noop('The order has already been cancelled.') + elif trans.amount != trans.order.total: + trans.state = BankTransaction.STATE_INVALID + trans.message = ugettext_noop('The transaction amount is incorrect.') + else: + try: + mark_order_paid(trans.order, provider='banktransfer', info=json.dumps({ + 'reference': trans.reference, + 'date': trans.date.isoformat(), + 'payer': trans.payer, + 'trans_id': trans.pk + })) + except Quota.QuotaExceededException as e: + trans.state = BankTransaction.STATE_ERROR + trans.message = str(e) + except SendMailException: + trans.state = BankTransaction.STATE_ERROR + trans.message = ugettext_noop('Problem sending email.') + else: + trans.state = BankTransaction.STATE_VALID + trans.save() + + +def _get_unknown_transactions(event: Event, job: BankImportJob, data: list): + amount_pattern = re.compile("[^0-9.-]") + known_checksums = set(t['checksum'] for t in BankTransaction.objects.filter(event=event).values('checksum')) + + transactions = [] + for row in data: + amount = amount_pattern.sub("", row['amount'].replace(",", ".")) + try: + amount = Decimal(amount) + except: + logger.exception('Could not parse amount of transaction: {}'.format(amount)) + amount = Decimal("0.00") + + date = dateutil.parser.parse(row['date']) + trans = BankTransaction(event=event, import_job=job, + payer=row['payer'], + reference=row['reference'], + amount=amount, + date=date) + trans.checksum = trans.calculate_checksum() + if trans.checksum not in known_checksums: + trans.state = BankTransaction.STATE_UNCHECKED + trans.save() + transactions.append(trans) + known_checksums.add(trans.checksum) + + return transactions + + +def process_banktransfers(event: int, job: int, data: list) -> None: + with language("en"): # We'll translate error messages at display time + event = Event.objects.get(pk=event) + job = BankImportJob.objects.get(pk=job) + job.state = BankImportJob.STATE_RUNNING + job.save() + + try: + transactions = _get_unknown_transactions(event, job, data) + + code_len = settings.ENTROPY['order_code'] + pattern = re.compile(event.slug.upper() + "[ -_]*([A-Z0-9]{%s})" % code_len) + + for trans in transactions: + match = pattern.search(trans.reference.upper()) + + if match: + code = match.group(1) + with transaction.atomic(): + _handle_transaction(event, trans, code) + else: + trans.state = BankTransaction.STATE_NOMATCH + trans.save() + except Exception as e: + job.state = BankImportJob.STATE_ERROR + job.save() + raise e + else: + job.state = BankImportJob.STATE_COMPLETED + job.save() + + +if settings.HAS_CELERY: + from pretix.celery import app + + process_task = app.task(process_banktransfers) + + def process_banktransfers(*args, **kwargs): + process_task.apply_async(args=args, kwargs=kwargs) diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control_head.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control_head.html new file mode 100644 index 0000000000..2c7c881271 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control_head.html @@ -0,0 +1,6 @@ +{% load staticfiles %} +{% load compress %} + +{% compress css %} + +{% endcompress %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/hbci_log.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/hbci_log.html deleted file mode 100644 index a4311e7c7e..0000000000 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/hbci_log.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "pretixplugins/banktransfer/import_base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% block inner %} -
- {% trans "An error occured during the HBCI transaction." %} -
-
{{ log }}
-{% 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 index c0e6a5b42e..b391089e7d 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_base.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_base.html @@ -1,8 +1,10 @@ {% extends "pretixcontrol/event/base.html" %} {% load i18n %} +{% load static %} {% 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 deleted file mode 100644 index 3bf63b9fd2..0000000000 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_confirm.html +++ /dev/null @@ -1,54 +0,0 @@ -{% 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 index 67561965d0..4575160f53 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html @@ -16,7 +16,7 @@
{% csrf_token %}
- +