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

@@ -5,5 +5,8 @@ class PretixApiConfig(AppConfig):
name = 'pretix.api'
label = 'pretixapi'
def ready(self):
from . import signals, webhooks # noqa
default_app_config = 'pretix.api.PretixApiConfig'

View File

@@ -0,0 +1,79 @@
# Generated by Django 2.1.1 on 2018-11-07 10:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0102_auto_20181017_0024'),
('pretixapi', '0002_auto_20180604_1120'),
]
operations = [
migrations.CreateModel(
name='WebHook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enabled', models.BooleanField(default=True, verbose_name='Enable webhook')),
('target_url', models.URLField(verbose_name='Target URL')),
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer')),
],
),
migrations.CreateModel(
name='WebHookCall',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True)),
('target_url', models.URLField()),
('is_retry', models.BooleanField(default=False)),
('execution_time', models.FloatField(null=True)),
('return_code', models.PositiveIntegerField(default=0)),
('payload', models.TextField()),
('response_body', models.TextField()),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
],
),
migrations.CreateModel(
name='WebHookEventListener',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
],
),
migrations.AddField(
model_name='webhookcall',
name='success',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='webhook',
name='all_events',
field=models.BooleanField(default=True, verbose_name='All events (including newly created ones)'),
),
migrations.AlterField(
model_name='webhook',
name='organizer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='pretixbase.Organizer'),
),
migrations.AlterField(
model_name='webhookcall',
name='webhook',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calls', to='pretixapi.WebHook'),
),
migrations.AlterField(
model_name='webhookeventlistener',
name='webhook',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listeners', to='pretixapi.WebHook'),
),
migrations.AddField(
model_name='webhookcall',
name='action_type',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
]

View File

@@ -68,3 +68,41 @@ class OAuthRefreshToken(AbstractRefreshToken):
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
related_name="refresh_token"
)
class WebHook(models.Model):
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
target_url = models.URLField(verbose_name=_("Target URL"))
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
@property
def action_types(self):
return [
l.action_type for l in self.listeners.all()
]
class WebHookEventListener(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='listeners')
action_type = models.CharField(max_length=255)
class Meta:
ordering = ("action_type",)
class WebHookCall(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
datetime = models.DateTimeField(auto_now_add=True)
target_url = models.URLField()
action_type = models.CharField(max_length=255)
is_retry = models.BooleanField(default=False)
execution_time = models.FloatField(null=True)
return_code = models.PositiveIntegerField(default=0)
success = models.BooleanField(default=False)
payload = models.TextField()
response_body = models.TextField()
class Meta:
ordering = ("-datetime",)

View File

@@ -0,0 +1,71 @@
from django.core.exceptions import ValidationError
from rest_framework import serializers
from pretix.api.models import WebHook
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.models import Event
class EventRelatedField(serializers.SlugRelatedField):
def get_queryset(self):
return self.context['organizer'].events.all()
class ActionTypesField(serializers.Field):
def to_representation(self, instance: WebHook):
return instance.action_types
def to_internal_value(self, data):
types = get_all_webhook_events()
for d in data:
if d not in types:
raise ValidationError('Invalid action type "%s".' % d)
return {'action_types': data}
class WebHookSerializer(I18nAwareModelSerializer):
limit_events = EventRelatedField(
slug_field='slug',
queryset=Event.objects.none(),
many=True
)
action_types = ActionTypesField(source='*')
class Meta:
model = WebHook
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
for event in full_data.get('limit_events'):
if self.context['organizer'] != event.organizer:
raise ValidationError('One or more events do not belong to this organizer.')
if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('You can set either limit_events or all_events.')
return data
def create(self, validated_data):
action_types = validated_data.pop('action_types')
inst = super().create(validated_data)
for l in action_types:
inst.listeners.create(action_type=l)
return inst
def update(self, instance, validated_data):
action_types = validated_data.pop('action_types', None)
instance = super().update(instance, validated_data)
if action_types is not None:
current_listeners = set(instance.listeners.values_list('action_type', flat=True))
new_listeners = set(action_types)
for l in current_listeners - new_listeners:
instance.listeners.filter(action_type=l).delete()
for l in new_listeners - current_listeners:
instance.listeners.create(action_type=l)
return instance

21
src/pretix/api/signals.py Normal file
View File

@@ -0,0 +1,21 @@
from datetime import timedelta
from django.dispatch import Signal, receiver
from django.utils.timezone import now
from pretix.api.models import WebHookCall
from pretix.base.signals import periodic_task
register_webhook_events = Signal(
providing_args=[]
)
"""
This signal is sent out to get all known webhook events. Receivers should return an
instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
instances.
"""
@receiver(periodic_task)
def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()

View File

@@ -8,7 +8,7 @@ from pretix.api.views import cart
from .views import (
checkin, device, event, item, oauth, order, organizer, voucher,
waitinglist,
waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -17,6 +17,7 @@ router.register(r'organizers', organizer.OrganizerViewSet)
orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)

View File

@@ -244,7 +244,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ignore_unpaid=ignore_unpaid,
nonce=nonce,
datetime=dt,
questions_supported=self.request.data.get('questions_supported', True)
questions_supported=self.request.data.get('questions_supported', True),
user=self.request.user,
auth=self.request.auth,
)
except RequiredQuestionsError as e:
return Response({

View File

@@ -0,0 +1,49 @@
from rest_framework import viewsets
from pretix.api.models import WebHook
from pretix.api.serializers.webhooks import WebHookSerializer
from pretix.helpers.dicts import merge_dicts
class WebHookViewSet(viewsets.ModelViewSet):
serializer_class = WebHookSerializer
queryset = WebHook.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.webhooks.prefetch_related('listeners')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.webhook.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.webhook.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
return inst
def perform_destroy(self, instance):
self.request.organizer.log_action(
'pretix.webhook.changed',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk, 'enabled': False}
)
instance.enabled = False
instance.save(update_fields=['enabled'])

252
src/pretix/api/webhooks.py Normal file
View File

@@ -0,0 +1,252 @@
import json
import logging
import time
from collections import OrderedDict
import requests
from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
from pretix.api.signals import register_webhook_events
from pretix.base.models import LogEntry
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
_ALL_EVENTS = None
class WebhookEvent:
def __init__(self):
pass
def __repr__(self):
return '<WebhookEvent: {}>'.format(self.action_type)
@property
def action_type(self) -> str:
"""
The action_type string that this notification handles, for example
``"pretix.event.order.paid"``. Only one notification type should be registered
per action type.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this notification type.
"""
raise NotImplementedError() # NOQA
def build_payload(self, logentry: LogEntry) -> dict:
"""
This is the main function that you should override. It is supposed to turn a log entry
object into a dictionary that can be used as the webhook payload.
"""
raise NotImplementedError() # NOQA
def get_all_webhook_events():
global _ALL_EVENTS
if _ALL_EVENTS:
return _ALL_EVENTS
types = OrderedDict()
for recv, ret in register_webhook_events.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.action_type] = r
else:
types[ret.action_type] = ret
_ALL_EVENTS = types
return types
class ParametrizedOrderWebhookEvent(WebhookEvent):
def __init__(self, action_type, verbose_name):
self._action_type = action_type
self._verbose_name = verbose_name
super().__init__()
@property
def action_type(self):
return self._action_type
@property
def verbose_name(self):
return self._verbose_name
def build_payload(self, logentry: LogEntry):
order = logentry.content_object
return {
'notification_id': logentry.pk,
'organizer': order.event.organizer.slug,
'event': order.event.slug,
'code': order.code,
'action': logentry.action_type,
}
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
def build_payload(self, logentry: LogEntry):
d = super().build_payload(logentry)
d['orderposition_id'] = logentry.parsed_data.get('position')
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
d['checkin_list'] = logentry.parsed_data.get('list')
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
def register_default_webhook_events(sender, **kwargs):
return (
ParametrizedOrderWebhookEvent(
'pretix.event.order.placed',
_('New order placed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.paid',
_('Order marked as paid'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.canceled',
_('Order canceled'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.expired',
_('Order expired'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.modified',
_('Order information changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.contact.changed',
_('Order contact address changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.changed.*',
_('Order changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refunded',
_('Order refunded'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.denied',
_('Order denied'),
),
ParametrizedOrderPositionWebhookEvent(
'pretix.event.checkin',
_('Ticket checked in'),
),
ParametrizedOrderPositionWebhookEvent(
'pretix.event.checkin.reverted',
_('Ticket check-in reverted'),
),
)
@app.task(base=TransactionAwareTask)
def notify_webhooks(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id)
if not logentry.organizer:
return # We need to know the organizer
types = get_all_webhook_events()
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if not notification_type:
return # Ignore, no webhooks for this event type
# All webhooks that registered for this notification
event_listener = WebHookEventListener.objects.filter(
webhook=OuterRef('pk'),
action_type=notification_type.action_type
)
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
organizer=logentry.organizer,
has_el=True,
enabled=True
)
if logentry.event_id:
webhooks = webhooks.filter(
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
)
for wh in webhooks:
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
@app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
t = time.time()
try:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError:
pass

View File

@@ -53,6 +53,7 @@ class LoggingMixin:
from .organizer import TeamAPIToken
from ..notifications import get_all_notification_types
from ..services.notifications import notify
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
event = None
if isinstance(self, Event):
@@ -80,8 +81,21 @@ class LoggingMixin:
if save:
logentry.save()
if action in get_all_notification_types():
no_types = get_all_notification_types()
wh_types = get_all_webhook_events()
no_type = None
wh_type = None
typepath = logentry.action_type
while (not no_type or not wh_types) and '.' in typepath:
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if no_type:
notify.apply_async(args=(logentry.pk,))
if wh_type:
notify_webhooks.apply_async(args=(logentry.pk,))
return logentry

View File

@@ -63,6 +63,16 @@ class LogEntry(models.Model):
return response
return self.action_type
@cached_property
def organizer(self):
if self.event:
return self.event.organizer
elif hasattr(self.content_object, 'event'):
return self.content_object.event.organizer
elif hasattr(self.content_object, 'organizer'):
return self.content_object.organizer
return None
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent

View File

@@ -225,7 +225,7 @@ def register_default_notification_types(sender, **kwargs):
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.changed',
'pretix.event.order.changed.*',
_('Order changed'),
_('Order {order.code} has been changed.')
),

View File

@@ -59,7 +59,8 @@ def _save_answers(op, answers, given_answers):
@transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True):
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None):
"""
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time.
@@ -133,7 +134,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'forced': op.order.status != Order.STATUS_PAID,
'datetime': dt,
'list': clist.pk
})
}, user=user, auth=auth)
else:
if not force:
raise CheckInError(
@@ -147,4 +148,4 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'forced': force,
'datetime': dt,
'list': clist.pk
})
}, user=user, auth=auth)

View File

@@ -17,9 +17,15 @@ def notify(logentry_id: int):
if not logentry.event:
return # Ignore, we only have event-related notifications right now
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if not notification_type:
return # Ignore, e.g. plugin not active for this event
return # No suitable plugin
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
@@ -33,7 +39,7 @@ def notify(logentry_id: int):
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=logentry.action_type,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
@@ -41,7 +47,7 @@ def notify(logentry_id: int):
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event__isnull=True,
action_type=logentry.action_type,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
@@ -49,20 +55,20 @@ def notify(logentry_id: int):
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry_id, user.pk, method))
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry_id, user.pk, method))
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
@app.task(base=ProfiledTask)
def send_notification(logentry_id: int, user_id: int, method: str):
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
notification_type = types.get(action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event

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 (

View File

@@ -68,6 +68,7 @@ def team(organizer):
can_change_vouchers=True,
can_view_vouchers=True,
can_change_orders=True,
can_change_organizer_settings=True
)

View File

@@ -124,6 +124,15 @@ event_permission_sub_urls = [
('delete', 'can_change_orders', 'cartpositions/1/', 404),
]
org_permission_sub_urls = [
('get', 'can_change_organizer_settings', 'webhooks/', 200),
('post', 'can_change_organizer_settings', 'webhooks/', 400),
('get', 'can_change_organizer_settings', 'webhooks/1/', 404),
('put', 'can_change_organizer_settings', 'webhooks/1/', 404),
('patch', 'can_change_organizer_settings', 'webhooks/1/', 404),
('delete', 'can_change_organizer_settings', 'webhooks/1/', 404),
]
event_permission_root_urls = [
('post', 'can_create_events', 400),
@@ -400,3 +409,32 @@ def test_device_subresource_permission_check(device_client, device, organizer, e
assert resp.status_code == 403
else:
assert resp.status_code in (404, 403)
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", org_permission_sub_urls)
def test_token_org_subresources_permission_allowed(token_client, team, organizer, event, urlset):
team.all_events = True
if urlset[1]:
setattr(team, urlset[1], True)
team.save()
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format(
organizer.slug, urlset[2]))
assert resp.status_code == urlset[3]
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", org_permission_sub_urls)
def test_token_org_subresources_permission_not_allowed(token_client, team, organizer, event, urlset):
if urlset[1] is None:
team.all_events = False
else:
team.all_events = True
setattr(team, urlset[1], False)
team.save()
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format(
organizer.slug, urlset[2]))
if urlset[3] == 404:
assert resp.status_code == 403
else:
assert resp.status_code in (404, 403)

View File

@@ -0,0 +1,169 @@
import copy
import pytest
from pretix.api.models import WebHook
@pytest.fixture
def webhook(organizer, event):
wh = organizer.webhooks.create(
enabled=True,
target_url='https://google.com',
all_events=False
)
wh.limit_events.add(event)
wh.listeners.create(action_type='pretix.event.order.placed')
wh.listeners.create(action_type='pretix.event.order.paid')
return wh
TEST_WEBHOOK_RES = {
"id": 1,
"enabled": True,
"target_url": "https://google.com",
"all_events": False,
"limit_events": ['dummy'],
"action_types": ['pretix.event.order.paid', 'pretix.event.order.placed'],
}
@pytest.mark.django_db
def test_hook_list(token_client, organizer, event, webhook):
res = dict(TEST_WEBHOOK_RES)
res["id"] = webhook.pk
resp = token_client.get('/api/v1/organizers/{}/webhooks/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_hook_detail(token_client, organizer, event, webhook):
res = dict(TEST_WEBHOOK_RES)
res["id"] = webhook.pk
resp = token_client.get('/api/v1/organizers/{}/webhooks/{}/'.format(organizer.slug, webhook.pk))
assert resp.status_code == 200
assert res == resp.data
TEST_WEBHOOK_CREATE_PAYLOAD = {
"enabled": True,
"target_url": "https://google.com",
"all_events": False,
"limit_events": ['dummy'],
"action_types": ['pretix.event.order.placed', 'pretix.event.order.paid'],
}
@pytest.mark.django_db
def test_hook_create(token_client, organizer, event):
resp = token_client.post(
'/api/v1/organizers/{}/webhooks/'.format(organizer.slug),
TEST_WEBHOOK_CREATE_PAYLOAD,
format='json'
)
assert resp.status_code == 201
cl = WebHook.objects.get(pk=resp.data['id'])
assert cl.target_url == "https://google.com"
assert cl.limit_events.count() == 1
assert set(cl.listeners.values_list('action_type', flat=True)) == {'pretix.event.order.placed',
'pretix.event.order.paid'}
assert not cl.all_events
@pytest.mark.django_db
def test_hook_create_either_all_or_limit(token_client, organizer, event):
res = copy.copy(TEST_WEBHOOK_CREATE_PAYLOAD)
res['all_events'] = True
resp = token_client.post(
'/api/v1/organizers/{}/webhooks/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'non_field_errors': ['You can set either limit_events or all_events.']}
@pytest.mark.django_db
def test_hook_create_invalid_url(token_client, organizer, event):
res = copy.copy(TEST_WEBHOOK_CREATE_PAYLOAD)
res['target_url'] = 'foo.bar'
resp = token_client.post(
'/api/v1/organizers/{}/webhooks/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'target_url': ['Enter a valid URL.']}
@pytest.mark.django_db
def test_hook_create_invalid_event(token_client, organizer, event):
res = copy.copy(TEST_WEBHOOK_CREATE_PAYLOAD)
res['limit_events'] = ['foo']
resp = token_client.post(
'/api/v1/organizers/{}/webhooks/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'limit_events': ['Object with slug=foo does not exist.']}
@pytest.mark.django_db
def test_hook_create_invalid_action_types(token_client, organizer, event):
res = copy.copy(TEST_WEBHOOK_CREATE_PAYLOAD)
res['action_types'] = ['foo']
resp = token_client.post(
'/api/v1/organizers/{}/webhooks/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'action_types': ['Invalid action type "foo".']}
@pytest.mark.django_db
def test_hook_patch_url(token_client, organizer, event, webhook):
resp = token_client.patch(
'/api/v1/organizers/{}/webhooks/{}/'.format(organizer.slug, webhook.pk),
{
'target_url': 'https://pretix.eu'
},
format='json'
)
assert resp.status_code == 200
webhook.refresh_from_db()
assert webhook.target_url == "https://pretix.eu"
assert webhook.limit_events.count() == 1
assert set(webhook.listeners.values_list('action_type', flat=True)) == {'pretix.event.order.placed',
'pretix.event.order.paid'}
assert webhook.enabled
@pytest.mark.django_db
def test_hook_patch_types(token_client, organizer, event, webhook):
resp = token_client.patch(
'/api/v1/organizers/{}/webhooks/{}/'.format(organizer.slug, webhook.pk),
{
'action_types': ['pretix.event.order.placed', 'pretix.event.order.canceled']
},
format='json'
)
assert resp.status_code == 200
webhook.refresh_from_db()
assert webhook.limit_events.count() == 1
assert set(webhook.listeners.values_list('action_type', flat=True)) == {'pretix.event.order.placed',
'pretix.event.order.canceled'}
assert webhook.enabled
@pytest.mark.django_db
def test_hook_delete(token_client, organizer, event, webhook):
resp = token_client.delete(
'/api/v1/organizers/{}/webhooks/{}/'.format(organizer.slug, webhook.pk),
)
assert resp.status_code == 204
webhook.refresh_from_db()
assert not webhook.enabled

View File

@@ -78,6 +78,17 @@ def test_notification_trigger_global(event, order, user, monkeypatch_on_commit):
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_trigger_global_wildcard(event, order, user, monkeypatch_on_commit):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.changed.*', enabled=True
)
with transaction.atomic():
order.log_action('pretix.event.order.changed.item', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_enabled_global_ignored_specific(event, order, user, monkeypatch_on_commit):
djmail.outbox = []

View File

@@ -0,0 +1,199 @@
import json
from datetime import timedelta
from decimal import Decimal
import pytest
import responses
from django.db import transaction
from django.utils.timezone import now
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
@pytest.fixture
def organizer():
return Organizer.objects.create(name='Dummy', slug='dummy')
@pytest.fixture
def event(organizer):
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=now()
)
return event
@pytest.fixture
def webhook(organizer, event):
wh = organizer.webhooks.create(
enabled=True,
target_url='https://google.com',
all_events=False
)
wh.limit_events.add(event)
wh.listeners.create(action_type='pretix.event.order.placed')
wh.listeners.create(action_type='pretix.event.order.paid')
return wh
@pytest.fixture
def order(event):
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('46.00'),
)
tr19 = event.tax_rules.create(rate=Decimal('19.00'))
ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr19,
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o, item=ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
return o
def force_str(v):
return v.decode() if isinstance(v, bytes) else str(v)
@pytest.fixture
def monkeypatch_on_commit(monkeypatch):
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_commit):
responses.add_callback(
responses.POST, 'https://google.com',
callback=lambda r: (200, {}, 'ok'),
content_type='application/json',
)
with transaction.atomic():
le = order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
"notification_id": le.pk,
"organizer": "dummy",
"event": "dummy",
"code": "FOO",
"action": "pretix.event.order.paid"
}
first = webhook.calls.last()
assert first.webhook == webhook
assert first.target_url == 'https://google.com'
assert first.action_type == 'pretix.event.order.paid'
assert not first.is_retry
assert first.return_code == 200
assert first.success
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit):
webhook.limit_events.clear()
webhook.all_events = True
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
le = order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
"notification_id": le.pk,
"organizer": "dummy",
"event": "dummy",
"code": "FOO",
"action": "pretix.event.order.paid"
}
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_commit):
webhook.listeners.create(action_type="pretix.event.order.changed.*")
webhook.limit_events.clear()
webhook.all_events = True
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
le = order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
"notification_id": le.pk,
"organizer": "dummy",
"event": "dummy",
"code": "FOO",
"action": "pretix.event.order.changed.item"
}
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_wrong_action_type(event, order, webhook, monkeypatch_on_commit):
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_disabled(event, order, webhook, monkeypatch_on_commit):
webhook.enabled = False
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit):
webhook.limit_events.clear()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@pytest.mark.django_db
@pytest.mark.xfail(reason="retries can't be tested with celery_always_eager")
@responses.activate
def test_webhook_retry(event, order, webhook, monkeypatch_on_commit):
responses.add(responses.POST, 'https://google.com', status=500)
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 2
second = webhook.objects.first()
first = webhook.objects.last()
assert first.webhook == webhook
assert first.target_url == 'https://google.com'
assert first.action_type == 'pretix.event.order.paid'
assert not first.is_retry
assert first.return_code == 500
assert not first.success
assert second.webhook == webhook
assert second.target_url == 'https://google.com'
assert second.action_type == 'pretix.event.order.paid'
assert first.is_retry
assert first.return_code == 200
assert first.success
@pytest.mark.django_db
@responses.activate
def test_webhook_disable_gone(event, order, webhook, monkeypatch_on_commit):
responses.add(responses.POST, 'https://google.com', status=410)
with transaction.atomic():
order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
webhook.refresh_from_db()
assert not webhook.enabled

View File

@@ -262,7 +262,7 @@ def test_manual_checkins(client, checkin_list_env):
})
assert checkin_list_env[5][3].checkins.exists()
assert LogEntry.objects.filter(
action_type='pretix.control.views.checkin', object_id=checkin_list_env[5][3].order.pk
action_type='pretix.event.checkin', object_id=checkin_list_env[5][3].order.pk
).exists()
@@ -279,10 +279,10 @@ def test_manual_checkins_revert(client, checkin_list_env):
})
assert not checkin_list_env[5][3].checkins.exists()
assert LogEntry.objects.filter(
action_type='pretix.control.views.checkin', object_id=checkin_list_env[5][3].order.pk
action_type='pretix.event.checkin', object_id=checkin_list_env[5][3].order.pk
).exists()
assert LogEntry.objects.filter(
action_type='pretix.control.views.checkin.reverted', object_id=checkin_list_env[5][3].order.pk
action_type='pretix.event.checkin.reverted', object_id=checkin_list_env[5][3].order.pk
).exists()

View File

@@ -137,6 +137,10 @@ organizer_urls = [
'organizer/abc/device/1/edit',
'organizer/abc/device/1/connect',
'organizer/abc/device/1/revoke',
'organizer/abc/webhooks',
'organizer/abc/webhook/add',
'organizer/abc/webhook/1/edit',
'organizer/abc/webhook/1/logs',
]
@@ -378,6 +382,15 @@ organizer_permission_urls = [
("can_change_teams", "organizer/dummy/team/1/delete", 200),
("can_change_organizer_settings", "organizer/dummy/edit", 200),
("can_change_organizer_settings", "organizer/dummy/settings/display", 200),
("can_change_organizer_settings", "organizer/dummy/devices", 200),
("can_change_organizer_settings", "organizer/dummy/device/add", 200),
("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404),
("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404),
("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404),
("can_change_organizer_settings", "organizer/dummy/webhooks", 200),
("can_change_organizer_settings", "organizer/dummy/webhook/add", 200),
("can_change_organizer_settings", "organizer/dummy/webhook/1/edit", 404),
("can_change_organizer_settings", "organizer/dummy/webhook/1/logs", 404),
]

View File

@@ -100,6 +100,7 @@ def logged_in_client(client, event):
('/control/organizer/{orga}/edit', 200),
('/control/organizer/{orga}/teams', 200),
('/control/organizer/{orga}/devices', 200),
('/control/organizer/{orga}/webhooks', 200),
('/control/events/', 200),
('/control/events/add', 200),

View File

@@ -0,0 +1,101 @@
import pytest
from django.utils.timezone import now
from pretix.api.models import WebHook
from pretix.base.models import Event, Organizer, Team, User
@pytest.fixture
def organizer():
return Organizer.objects.create(name='Dummy', slug='dummy')
@pytest.fixture
def event(organizer):
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=now()
)
return event
@pytest.fixture
def webhook(organizer, event):
wh = organizer.webhooks.create(
enabled=True,
target_url='https://google.com',
all_events=False
)
wh.limit_events.add(event)
wh.listeners.create(action_type='pretix.event.order.placed')
wh.listeners.create(action_type='pretix.event.order.paid')
return wh
@pytest.fixture
def admin_user(admin_team):
u = User.objects.create_user('dummy@dummy.dummy', 'dummy')
admin_team.members.add(u)
return u
@pytest.fixture
def admin_team(organizer):
return Team.objects.create(organizer=organizer, can_change_organizer_settings=True, name='Admin team')
@pytest.mark.django_db
def test_list_of_webhooks(event, admin_user, client, webhook):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.get('/control/organizer/dummy/webhooks')
assert 'https://google.com' in resp.rendered_content
@pytest.mark.django_db
def test_create_webhook(event, admin_user, admin_team, client):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/webhook/add', {
'target_url': 'https://google.com',
'enabled': 'on',
'events': 'pretix.event.order.paid',
'limit_events': str(event.pk),
}, follow=True)
w = WebHook.objects.last()
assert w.target_url == "https://google.com"
assert w.limit_events.count() == 1
assert list(w.listeners.values_list('action_type', flat=True)) == ['pretix.event.order.paid']
assert not w.all_events
@pytest.mark.django_db
def test_update_webhook(event, admin_user, admin_team, webhook, client):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/webhook/{}/edit'.format(webhook.pk), {
'target_url': 'https://google.com',
'enabled': 'on',
'events': ['pretix.event.order.paid', 'pretix.event.order.canceled'],
'limit_events': str(event.pk),
}, follow=True)
webhook.refresh_from_db()
assert webhook.target_url == "https://google.com"
assert webhook.limit_events.count() == 1
assert list(webhook.listeners.values_list('action_type', flat=True)) == ['pretix.event.order.canceled',
'pretix.event.order.paid']
assert not webhook.all_events
@pytest.mark.django_db
def test_webhook_logs(event, admin_user, admin_team, webhook, client):
client.login(email='dummy@dummy.dummy', password='dummy')
webhook.calls.create(
webhook=webhook,
action_type='pretix.event.order.paid',
target_url=webhook.target_url,
is_retry=False,
execution_time=2,
return_code=0,
payload='foo',
response_body='bar'
)
resp = client.get('/control/organizer/dummy/webhook/{}/logs'.format(webhook.pk))
assert 'pretix.event.order.paid' in resp.rendered_content