diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 3ae3021679..1b23ffd240 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -51,7 +51,7 @@ Backend .. automodule:: pretix.base.signals - :members: logentry_display + :members: logentry_display, requiredaction_display Vouchers """""""" diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index b9b1874871..bda59b9cb8 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -29,6 +29,9 @@ Organizers and events .. autoclass:: pretix.base.models.EventPermission :members: +.. autoclass:: pretix.base.models.RequiredAction + :members: + Items ----- diff --git a/src/pretix/base/migrations/0053_auto_20170104_1252.py b/src/pretix/base/migrations/0053_auto_20170104_1252.py new file mode 100644 index 0000000000..67f0fd2244 --- /dev/null +++ b/src/pretix/base/migrations/0053_auto_20170104_1252.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-01-04 12:52 +from __future__ import unicode_literals + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0052_auto_20161231_1533'), + ] + + operations = [ + migrations.CreateModel( + name='RequiredAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(auto_now_add=True, db_index=True)), + ('done', models.BooleanField(default=False)), + ('action_type', models.CharField(max_length=255)), + ('data', models.TextField(default='{}')), + ], + options={ + 'ordering': ('datetime',), + }, + ), + migrations.AlterField( + model_name='event', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'), + ), + migrations.AddField( + model_name='requiredaction', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'), + ), + migrations.AddField( + model_name='requiredaction', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index d980f55df2..5e8e722538 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -1,7 +1,9 @@ from .auth import U2FDevice, User from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin -from .event import Event, EventLock, EventPermission, EventSetting +from .event import ( + Event, EventLock, EventPermission, EventSetting, RequiredAction, +) from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 6cf56ce223..7cb28195cf 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -359,3 +359,42 @@ class EventLock(models.Model): event = models.CharField(max_length=36, primary_key=True) date = models.DateTimeField(auto_now=True) token = models.UUIDField(default=uuid.uuid4) + + +class RequiredAction(models.Model): + """ + Represents an action that is to be done by an admin. The admin will be + displayed a list of actions to do. + + :param datatime: The timestamp of the required action + :type datetime: datetime + :param user: The user that performed the action + :type user: User + :param done: If this action has been completed or dismissed + :type done: bool + :param action_type: The type of action that has to be performed. This is + used to look up the renderer used to describe the action in a human- + readable way. This should be some namespaced value using dotted + notation to avoid duplicates, e.g. + ``"pretix.plugins.banktransfer.incoming_transfer"``. + :type action_type: str + :param data: Arbitrary data that can be used by the log action renderer + :type data: str + """ + datetime = models.DateTimeField(auto_now_add=True, db_index=True) + done = models.BooleanField(default=False) + user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT) + event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE) + action_type = models.CharField(max_length=255) + data = models.TextField(default='{}') + + class Meta: + ordering = ('datetime',) + + def display(self, request): + from ..signals import requiredaction_display + + for receiver, response in requiredaction_display.send(self.event, action=self, request=request): + if response: + return response + return self.action_type diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index ba2b4d2c2c..715751fa09 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -110,7 +110,21 @@ To display an instance of the ``LogEntry`` model to a human user, ``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument. The first received response that is not ``None`` will be used to display the log entry -to the user. +to the user. The receivers are expected to return plain text. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +requiredaction_display = EventPluginSignal( + providing_args=["action", "request"] +) +""" +To display an instance of the ``RequiredAction`` model to a human user, +``pretix.base.signals.requiredaction_display`` will be sent out with a ``action`` argument. +You will also get the current ``request`` in a different argument. + +The first received response that is not ``None`` will be used to display the log entry +to the user. The receivers are expected to return HTML code. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ diff --git a/src/pretix/control/templates/pretixcontrol/event/actions.html b/src/pretix/control/templates/pretixcontrol/event/actions.html new file mode 100644 index 0000000000..86a1714be6 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/actions.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% block title %}{% trans "Current issues" %}{% endblock %} +{% block inside %} +

{% trans "Current issues" %}

+ + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 834e0ab6bf..6ac7e54859 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -9,6 +9,36 @@ {% trans "Go to shop" %} + + {% if actions|length > 0 %} +
+
+

+ {% trans "Your attention is required to resolve the following issues" %} +

+
+ + +
+ {% endif %} +
{% for w in widgets %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index eac3400d40..c1beb830bc 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -38,6 +38,9 @@ urlpatterns = [ url(r'^$', dashboards.event_index, name='event.index'), url(r'^live/$', event.EventLive.as_view(), name='event.live'), url(r'^logs/$', event.EventLog.as_view(), name='event.log'), + url(r'^requiredactions/$', event.EventActions.as_view(), name='event.requiredactions'), + url(r'^requiredactions/(?P\d+)/discard$', event.EventActionDiscard.as_view(), + name='event.requiredaction.discard'), url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'), url(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'), url(r'^settings/permissions$', event.EventPermissions.as_view(), name='event.settings.permissions'), diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index b8caa7bef1..50b03693f5 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -165,10 +165,15 @@ def event_index(request, organizer, event): if not request.eventperm.can_view_vouchers: qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher)) + a_qs = request.event.requiredaction_set.filter(done=False) + ctx = { 'widgets': rearrange(widgets), - 'logs': qs[:5] + 'logs': qs[:5], + 'actions': a_qs[:5] if request.eventperm.can_change_orders else [] } + for a in ctx['actions']: + a.display = a.display(request) return render(request, 'pretixcontrol/event/index.html', ctx) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 59adad3ab5..19ee125c85 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -8,7 +8,7 @@ from django.core.urlresolvers import reverse from django.db import transaction from django.forms import modelformset_factory from django.http import HttpResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.views.generic import FormView, ListView @@ -18,7 +18,7 @@ from django.views.generic.detail import SingleObjectMixin from pretix.base.forms import I18nModelForm from pretix.base.models import ( CachedTicket, Event, EventPermission, Item, ItemVariation, LogEntry, Order, - User, Voucher, + RequiredAction, User, Voucher, ) from pretix.base.services import tickets from pretix.base.services.invoices import build_preview_invoice_pdf @@ -681,3 +681,39 @@ class EventLog(EventPermissionRequiredMixin, ListView): ctx = super().get_context_data() ctx['userlist'] = self.request.event.user_perms.select_related('user') return ctx + + +class EventActions(EventPermissionRequiredMixin, ListView): + template_name = 'pretixcontrol/event/actions.html' + model = RequiredAction + context_object_name = 'actions' + paginate_by = 20 + permission = 'can_change_orders' + + def get_queryset(self): + qs = self.request.event.requiredaction_set.filter(done=False) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + for a in ctx['actions']: + a.display = a.display(self.request) + return ctx + + +class EventActionDiscard(EventPermissionRequiredMixin, View): + permission = 'can_change_orders' + + def get(self, request, **kwargs): + action = get_object_or_404(RequiredAction, event=request.event, pk=kwargs.get('id')) + action.done = True + action.user = request.user + action.save() + messages.success(self.request, _('The issue has been marked as resolved!')) + return redirect(self.get_success_url()) + + def get_success_url(self) -> str: + return reverse('control:event.index', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug + }) diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index a841765ed0..354dd55b05 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -5,7 +5,9 @@ from django.dispatch import receiver from django.template.loader import get_template from django.utils.translation import ugettext_lazy as _ -from pretix.base.signals import logentry_display, register_payment_providers +from pretix.base.signals import ( + logentry_display, register_payment_providers, requiredaction_display, +) from pretix.presale.signals import html_head @@ -57,3 +59,14 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): if text: return _('Stripe reported an event: {}').format(text) + + +@receiver(signal=requiredaction_display, dispatch_uid="stripe_requiredaction_display") +def pretixcontrol_action_display(sender, action, request, **kwargs): + if action.action_type != 'pretix.plugins.stripe.refund': + return + + data = json.loads(action.data) + template = get_template('pretixplugins/stripe/action_refund.html') + ctx = {'data': data, 'event': sender, 'action': action} + return template.render(ctx, request) diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html new file mode 100644 index 0000000000..8edb969315 --- /dev/null +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html @@ -0,0 +1,20 @@ +{% load i18n %} + +

+ {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} + {% blocktrans trimmed with charge=data.charge stripe_href="href='https://dashboard.stripe.com/payments/"|add:data.charge|add:"' target='_blank'"|safe order=""|add:data.order|add:""|safe %} + Stripe reported that the transaction {{ charge }} has been refunded. + Do you want to refund the matching order ({{ order }}) as well? + {% endblocktrans %} +

+
+ {% csrf_token %} + + {% trans "No" %} + +   + {% trans "This action cannot be undone." %} +
diff --git a/src/pretix/plugins/stripe/urls.py b/src/pretix/plugins/stripe/urls.py index 473dbba0e9..a70126311b 100644 --- a/src/pretix/plugins/stripe/urls.py +++ b/src/pretix/plugins/stripe/urls.py @@ -1,9 +1,14 @@ from django.conf.urls import include, url -from .views import webhook +from .views import refund, webhook event_patterns = [ url(r'^stripe/', include([ url(r'^webhook/$', webhook, name='webhook'), ])), ] + +urlpatterns = [ + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/stripe/refund/(?P\d+)/', + refund, name='refund'), +] diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index 09ca7d23a0..ef3924eff2 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -2,12 +2,18 @@ import json import logging import stripe +from django.contrib import messages +from django.db import transaction from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from pretix.base.models import Order +from pretix.base.models import Order, RequiredAction from pretix.base.services.orders import mark_order_paid, mark_order_refunded +from pretix.control.permissions import event_permission_required from pretix.plugins.stripe.payment import Stripe from pretix.presale.utils import event_view @@ -56,8 +62,39 @@ def webhook(request, *args, **kwargs): is_refund = charge['refunds']['total_count'] or charge['dispute'] if order.status == Order.STATUS_PAID and is_refund: - mark_order_refunded(order, user=None) + RequiredAction.objects.create( + event=request.event, action_type='pretix.plugins.stripe.refund', data=json.dumps({ + 'order': order.code, + 'charge': charge_id + }) + ) elif order.status == Order.STATUS_PENDING and charge['status'] == 'succeeded' and not is_refund: mark_order_paid(order, user=None) return HttpResponse(status=200) + + +@event_permission_required('can_view_orders') +@require_POST +def refund(request, **kwargs): + with transaction.atomic(): + action = get_object_or_404(RequiredAction, event=request.event, pk=kwargs.get('id'), + action_type='pretix.plugins.stripe.refund', done=False) + data = json.loads(action.data) + action.done = True + action.user = request.user + action.save() + order = get_object_or_404(Order, event=request.event, code=data['order']) + if order.status != Order.STATUS_PAID: + messages.error(request, _('The order cannot be marked as refunded as it is not marked as paid!')) + else: + mark_order_refunded(order, user=request.user) + messages.success( + request, _('The order has been marked as refunded and the issue has been marked as resolved!') + ) + + return redirect(reverse('control:event.order', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + 'code': data['order'] + })) diff --git a/src/tests/plugins/stripe/test_webhook.py b/src/tests/plugins/stripe/test_webhook.py index 46132a5631..2d65525ecc 100644 --- a/src/tests/plugins/stripe/test_webhook.py +++ b/src/tests/plugins/stripe/test_webhook.py @@ -5,16 +5,20 @@ from decimal import Decimal import pytest from django.utils.timezone import now -from pretix.base.models import Event, Order, Organizer +from pretix.base.models import ( + Event, EventPermission, Order, Organizer, RequiredAction, User, +) @pytest.fixture def env(): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') o = Organizer.objects.create(name='Dummy', slug='dummy') event = Event.objects.create( organizer=o, name='Dummy', slug='dummy', date_from=now(), live=True ) + EventPermission.objects.create(event=event, user=user) o1 = Order.objects.create( code='FOOBAR', event=event, email='dummy@dummy.test', status=Order.STATUS_PAID, @@ -198,6 +202,14 @@ def test_webhook_partial_refund(env, client, monkeypatch): } ), content_type='application_json') + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + ra = RequiredAction.objects.get(action_type="pretix.plugins.stripe.refund") + client.login(username='dummy@dummy.dummy', password='dummy') + client.post('/control/event/dummy/dummy/stripe/refund/{}/'.format(ra.pk)) + order = env[1] order.refresh_from_db() assert order.status == Order.STATUS_REFUNDED