Fix #526 -- Add a webhook system (#1073)

- [x] Data model
- [x] UI
- [x] Fire hooks
- [x] Unit tests
- [x] Display logs
- [x] API to modify hooks
- [x] Documentation
- [x] More hooks!
This commit is contained in:
Raphael Michel
2018-11-08 16:38:05 +01:00
committed by GitHub
parent 74e8e73877
commit c2d03f5e6b
36 changed files with 1442 additions and 31 deletions

View File

@@ -2,9 +2,12 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Device, Organizer, Team
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
@@ -222,3 +225,32 @@ class OrganizerDisplaySettingsForm(SettingsForm):
self.fields['primary_font'].choices += [
(a, a) for a in get_fonts()
]
class WebHookForm(forms.ModelForm):
events = forms.MultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
label=pgettext_lazy('webhooks', 'Event types')
)
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all()
self.fields['events'].choices = [
(
a.action_type,
mark_safe('{} <code>{}</code>'.format(a.verbose_name, a.action_type))
) for a in get_all_webhook_events().values()
]
if self.instance:
self.fields['events'].initial = list(self.instance.listeners.values_list('action_type', flat=True))
class Meta:
model = WebHook
fields = ['target_url', 'enabled', 'all_events', 'limit_events']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events'
}),
}

View File

@@ -123,13 +123,13 @@ def _display_checkin(event, logentry):
if data.get('first'):
if show_dt:
return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format(
return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format(
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _('Position #{posid} has been scanned for list "{list}".').format(
return _('Position #{posid} has been checked in for list "{list}".').format(
posid=data.get('positionid'),
list=checkin_list
)
@@ -321,6 +321,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
return _display_checkin(sender, logentry)
if logentry.action_type == 'pretix.control.views.checkin':
# deprecated
dt = dateutil.parser.parse(data.get('datetime'))
tz = pytz.timezone(sender.settings.timezone)
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
@@ -344,7 +345,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
list=checkin_list
)
if logentry.action_type == 'pretix.control.views.checkin.reverted':
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name

View File

@@ -46,6 +46,13 @@
</a>
</li>
{% endif %}
{% if 'can_change_organizer_settings' in request.orgapermset %}
<li {% if "organizer.webhook" in url_name %}class="active"{% endif %}>
<a href="{% url "control:organizer.webhooks" organizer=organizer.slug %}">
{% trans "Webhooks" %}
</a>
</li>
{% endif %}
{% for nav in nav_organizer %}
<li {% if nav.active %}class="active"{% endif %}>
<a href="{{ nav.url }}">

View File

@@ -0,0 +1,24 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
{% if webhook %}
<legend>{% trans "Modify webhook" %}</legend>
{% else %}
<legend>{% trans "Create a new webhook" %}</legend>
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.target_url layout="control" %}
{% bootstrap_field form.enabled layout="control" %}
{% bootstrap_field form.events layout="control" %}
{% bootstrap_field form.all_events layout="control" %}
{% bootstrap_field form.limit_events layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<legend>{% blocktrans with url=webhook.target_url %}Logs for webhook {{ url }}{% endblocktrans %}</legend>
<p>
{% trans "This page shows all calls to your webhook in the past 30 days." %}
</p>
{% for c in calls %}
<details class="panel panel-default">
<summary class="panel-heading">
<div class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
{% if c.is_retry %}
<span class="fa fa-repeat fa-fw" data-toggle="tooltip" title="{% trans "This webhook was retried since it previously failed." %}"></span>
{% else %}
<span class="fa fa-clock-o fa-fw"></span>
{% endif %}
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<span class="fa fa-tag fa-fw"></span>
{{ c.action_type }}
</div>
<div class="col-md-2 col-sm-2 col-xs-4">
<span class="fa fa-hourglass fa-fw"></span>
{{ c.execution_time|floatformat:2 }}s
</div>
<div class="col-md-2 col-xs-8 text-right">
{% if c.success %}
<span class="label label-success">
<span class="fa fa-check-circle fa-fw"></span>
{{ c.return_code }}
</span>
{% else %}
{% if c.return_code %}
<span class="label label-danger">
<span class="fa fa-warning fa-fw"></span>
{{ c.return_code }}
</span>
{% else %}
<span class="label label-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Failed" %}
</span>
{% endif %}
{% endif %}
</div>
</div>
</summary>
<div id="{{ c.pk }}">
<div class="panel-body">
<strong>{% trans "Request URL" %}</strong>
<pre><code>POST {{ c.target_url }}</code></pre>
<strong>{% trans "Request POST body" %}</strong>
<pre><code>{{ c.payload }}</code></pre>
<strong>{% trans "Response body" %}</strong>
<pre><code>{{ c.response_body }}</code></pre>
</div>
</div>
</details>
{% empty %}
<div class="alert-info">{% trans "This webhook did not receive any events in the last 30 days." %}</div>
{% endfor %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<legend>
{% trans "Webhooks" %}
</legend>
<p>
{% blocktrans trimmed %}
This menu allows you to create webhooks to connect pretix to other online services.
{% endblocktrans %}
</p>
{% if webhooks|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any webhooks yet.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.webhook.add" organizer=request.organizer.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create webhook" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:organizer.webhook.add" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create webhook" %}</a>
</p>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Target URL" %}</th>
<th>{% trans "Events" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for w in webhooks %}
<tr>
<td>
{% if not w.enabled %}<del>{% endif %}
{{ w.target_url }}
{% if not w.enabled %}</del>{% endif %}
</td>
<td>
{% if w.all_events %}
{% trans "All" %}
{% else %}
<ul>
{% for e in w.limit_events.all %}
<li>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
{{ e }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:organizer.webhook.edit" organizer=request.organizer.slug webhook=w.id %}"
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Edit" %}">
<i class="fa fa-edit"></i>
</a>
<a href="{% url "control:organizer.webhook.logs" organizer=request.organizer.slug webhook=w.id %}"
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Logs" %}">
<i class="fa fa-list"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -71,6 +71,13 @@ urlpatterns = [
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
name='organizer.display'),
url(r'^organizer/(?P<organizer>[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
url(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
name='organizer.webhook.add'),
url(r'^organizer/(?P<organizer>[^/]+)/webhook/(?P<webhook>[^/]+)/edit$', organizer.WebHookUpdateView.as_view(),
name='organizer.webhook.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/webhook/(?P<webhook>[^/]+)/logs$', organizer.WebHookLogsView.as_view(),
name='organizer.webhook.logs'),
url(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
url(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
name='organizer.device.add'),

View File

@@ -94,10 +94,11 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
Checkin.objects.filter(position=op, list=self.list).delete()
op.order.log_action('pretix.control.views.checkin.reverted', data={
op.order.log_action('pretix.event.checkin.reverted', data={
'position': op.id,
'positionid': op.positionid,
'list': self.list.pk
'list': self.list.pk,
'web': True
}, user=request.user)
messages.success(request, _('The selected check-ins have been reverted.'))
@@ -108,12 +109,14 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
ci, created = Checkin.objects.get_or_create(position=op, list=self.list, defaults={
'datetime': now(),
})
op.order.log_action('pretix.control.views.checkin', data={
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
'positionid': op.positionid,
'first': created,
'forced': False,
'datetime': now(),
'list': self.list.pk
'list': self.list.pk,
'web': True
}, user=request.user)
messages.success(request, _('The selected tickets have been marked as checked in.'))

View File

@@ -17,6 +17,7 @@ from django.views.generic import (
CreateView, DeleteView, DetailView, FormView, ListView, UpdateView,
)
from pretix.api.models import WebHook
from pretix.base.models import Device, Organizer, Team, TeamInvite, User
from pretix.base.models.event import EventMetaProperty
from pretix.base.models.organizer import TeamAPIToken
@@ -25,13 +26,14 @@ from pretix.control.forms.filter import OrganizerFilterForm
from pretix.control.forms.organizer import (
DeviceForm, EventMetaPropertyForm, OrganizerDeleteForm,
OrganizerDisplaySettingsForm, OrganizerForm, OrganizerSettingsForm,
OrganizerUpdateForm, TeamForm,
OrganizerUpdateForm, TeamForm, WebHookForm,
)
from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
)
from pretix.control.signals import nav_organizer
from pretix.control.views import PaginationMixin
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.urls import build_absolute_uri
from pretix.presale.style import regenerate_organizer_css
@@ -761,3 +763,110 @@ class DeviceRevokeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
return redirect(reverse('control:organizer.devices', kwargs={
'organizer': self.request.organizer.slug,
}))
class WebHookListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = WebHook
template_name = 'pretixcontrol/organizers/webhooks.html'
permission = 'can_change_organizer_settings'
context_object_name = 'webhooks'
def get_queryset(self):
return self.request.organizer.webhooks.prefetch_related('limit_events')
class WebHookCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
model = WebHook
template_name = 'pretixcontrol/organizers/webhook_edit.html'
permission = 'can_change_organizer_settings'
form_class = WebHookForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['organizer'] = self.request.organizer
return kwargs
def get_success_url(self):
return reverse('control:organizer.webhooks', kwargs={
'organizer': self.request.organizer.slug,
})
def form_valid(self, form):
form.instance.organizer = self.request.organizer
ret = super().form_valid(form)
self.request.organizer.log_action('pretix.webhook.created', user=self.request.user, data=merge_dicts({
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data
}, {'id': form.instance.pk}))
new_listeners = set(form.cleaned_data['events'])
for l in new_listeners:
self.object.listeners.create(action_type=l)
return ret
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class WebHookUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
model = WebHook
template_name = 'pretixcontrol/organizers/webhook_edit.html'
permission = 'can_change_organizer_settings'
context_object_name = 'webhook'
form_class = WebHookForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['organizer'] = self.request.organizer
return kwargs
def get_object(self, queryset=None):
return get_object_or_404(WebHook, organizer=self.request.organizer, pk=self.kwargs.get('webhook'))
def get_success_url(self):
return reverse('control:organizer.webhooks', kwargs={
'organizer': self.request.organizer.slug,
})
def form_valid(self, form):
if form.has_changed():
self.request.organizer.log_action('pretix.webhook.changed', user=self.request.user, data=merge_dicts({
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data
}, {'id': form.instance.pk}))
current_listeners = set(self.object.listeners.values_list('action_type', flat=True))
new_listeners = set(form.cleaned_data['events'])
for l in current_listeners - new_listeners:
self.object.listeners.filter(action_type=l).delete()
for l in new_listeners - current_listeners:
self.object.listeners.create(action_type=l)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class WebHookLogsView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = WebHook
template_name = 'pretixcontrol/organizers/webhook_logs.html'
permission = 'can_change_organizer_settings'
context_object_name = 'calls'
paginate_by = 50
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['webhook'] = self.webhook
return ctx
@cached_property
def webhook(self):
return get_object_or_404(
WebHook, organizer=self.request.organizer, pk=self.kwargs.get('webhook')
)
def get_queryset(self):
return self.webhook.calls.order_by('-datetime')

View File

@@ -76,16 +76,16 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
return p
def generate(self, p: OrderPosition, override_layout=None, override_background=None):
raise NotImplemented
raise NotImplementedError()
def get_layout_settings_key(self):
raise NotImplemented
raise NotImplementedError()
def get_background_settings_key(self):
raise NotImplemented
raise NotImplementedError()
def get_default_background(self):
raise NotImplemented
raise NotImplementedError()
def get_current_background(self):
return (