Introduce RequiredAction model

Fix #343 by no longer marking as refunded automatically
This commit is contained in:
Raphael Michel
2017-01-04 15:04:18 +01:00
parent 871011826c
commit aed9382fd7
16 changed files with 303 additions and 11 deletions

View File

@@ -51,7 +51,7 @@ Backend
.. automodule:: pretix.base.signals
:members: logentry_display
:members: logentry_display, requiredaction_display
Vouchers
""""""""

View File

@@ -29,6 +29,9 @@ Organizers and events
.. autoclass:: pretix.base.models.EventPermission
:members:
.. autoclass:: pretix.base.models.RequiredAction
:members:
Items
-----

View File

@@ -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),
),
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -0,0 +1,25 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Current issues" %}{% endblock %}
{% block inside %}
<h1>{% trans "Current issues" %}</h1>
<ul class="list-group">
{% for action in actions %}
<li class="list-group-item logentry">
<p>
<a href="{% url "control:event.requiredaction.discard" event=request.event.slug organizer=request.event.organizer.slug id=action.id %}"
class="btn btn-default btn-xs pull-right">
{% trans "Hide message" %}
</a>
<small><span class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}</small>
</p>
{{ action.display|safe }}
</li>
{% empty %}
<div class="list-group-item">
<em>{% trans "No issues. Awesome!" %}</em>
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -9,6 +9,36 @@
{% trans "Go to shop" %}
</a>
</h1>
{% if actions|length > 0 %}
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Your attention is required to resolve the following issues" %}
</h3>
</div>
<ul class="list-group">
{% for action in actions %}
<li class="list-group-item logentry">
<p>
<a href="{% url "control:event.requiredaction.discard" event=request.event.slug organizer=request.event.organizer.slug id=action.id %}"
class="btn btn-default btn-xs pull-right">
{% trans "Hide message" %}
</a>
<small><span class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}</small>
</p>
{{ action.display|safe }}
</li>
{% endfor %}
</ul>
<div class="panel-footer">
<a href="{% url "control:event.requiredactions" event=request.event.slug organizer=request.event.organizer.slug %}">
{% trans "Show more" %}
</a>
</div>
</div>
{% endif %}
<div class="row dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }}">

View File

@@ -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<id>\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'),

View File

@@ -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)

View File

@@ -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
})

View File

@@ -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)

View File

@@ -0,0 +1,20 @@
{% load i18n %}
<p>
{% 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="<a href='"|add:ourl|add:"'>"|add:data.order|add:"</a>"|safe %}
Stripe reported that the transaction <a {{ stripe_href }}>{{ charge }}</a> has been refunded.
Do you want to refund the matching order ({{ order }}) as well?
{% endblocktrans %}
</p>
<form class="form-inline" method="post" action="{% url "plugins:stripe:refund" event=event.slug organizer=event.organizer.slug id=action.id %}">
{% csrf_token %}
<a href="{% url "control:event.requiredaction.discard" event=event.slug organizer=event.organizer.slug id=action.id %}"
class="btn btn-default">
{% trans "No" %}
</a>
<button type="submit" class="btn btn-default btn-danger">
{% trans "Yes, mark order as refunded" %}
</button>&nbsp;
{% trans "This action cannot be undone." %}
</form>

View File

@@ -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<organizer>[^/]+)/(?P<event>[^/]+)/stripe/refund/(?P<id>\d+)/',
refund, name='refund'),
]

View File

@@ -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']
}))

View File

@@ -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