From c2d03f5e6bfaa59f13cfedc53f6dd356c0976006 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 8 Nov 2018 16:38:05 +0100 Subject: [PATCH] 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! --- doc/api/index.rst | 1 + doc/api/resources/index.rst | 1 + doc/spelling_wordlist.txt | 1 + src/pretix/api/__init__.py | 3 + ...ebhook_webhookcall_webhookeventlistener.py | 79 ++++++ src/pretix/api/models.py | 38 +++ src/pretix/api/serializers/webhooks.py | 71 +++++ src/pretix/api/signals.py | 21 ++ src/pretix/api/urls.py | 3 +- src/pretix/api/views/checkin.py | 4 +- src/pretix/api/views/webhooks.py | 49 ++++ src/pretix/api/webhooks.py | 252 ++++++++++++++++++ src/pretix/base/models/base.py | 16 +- src/pretix/base/models/log.py | 10 + src/pretix/base/notifications.py | 2 +- src/pretix/base/services/checkin.py | 7 +- src/pretix/base/services/notifications.py | 22 +- src/pretix/control/forms/organizer.py | 34 ++- src/pretix/control/logdisplay.py | 7 +- .../pretixcontrol/organizers/base.html | 7 + .../organizers/webhook_edit.html | 24 ++ .../organizers/webhook_logs.html | 67 +++++ .../pretixcontrol/organizers/webhooks.html | 78 ++++++ src/pretix/control/urls.py | 7 + src/pretix/control/views/checkin.py | 11 +- src/pretix/control/views/organizer.py | 111 +++++++- src/pretix/control/views/pdf.py | 8 +- src/tests/api/conftest.py | 1 + src/tests/api/test_permissions.py | 38 +++ src/tests/api/test_webhooks.py | 169 ++++++++++++ src/tests/base/test_notifications.py | 11 + src/tests/base/test_webhooks.py | 199 ++++++++++++++ src/tests/control/test_checkins.py | 6 +- src/tests/control/test_permissions.py | 13 + src/tests/control/test_views.py | 1 + src/tests/control/test_webhooks.py | 101 +++++++ 36 files changed, 1442 insertions(+), 31 deletions(-) create mode 100644 src/pretix/api/migrations/0003_webhook_webhookcall_webhookeventlistener.py create mode 100644 src/pretix/api/serializers/webhooks.py create mode 100644 src/pretix/api/signals.py create mode 100644 src/pretix/api/views/webhooks.py create mode 100644 src/pretix/api/webhooks.py create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/webhook_edit.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/webhook_logs.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/webhooks.html create mode 100644 src/tests/api/test_webhooks.py create mode 100644 src/tests/base/test_webhooks.py create mode 100644 src/tests/control/test_webhooks.py diff --git a/doc/api/index.rst b/doc/api/index.rst index 9d5302ec07..674077f2d0 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -16,3 +16,4 @@ in functionality over time. fundamentals auth resources/index + webhooks diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index d65cc1b793..93ae4010d3 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -21,3 +21,4 @@ Resources and endpoints checkinlists waitinglist carts + webhooks diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index ed90ba67f8..15237072b3 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -105,6 +105,7 @@ subevent subevents submodule subpath +Symfony systemd testutils timestamp diff --git a/src/pretix/api/__init__.py b/src/pretix/api/__init__.py index f5e54ddd9c..08b1c5ab8e 100644 --- a/src/pretix/api/__init__.py +++ b/src/pretix/api/__init__.py @@ -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' diff --git a/src/pretix/api/migrations/0003_webhook_webhookcall_webhookeventlistener.py b/src/pretix/api/migrations/0003_webhook_webhookcall_webhookeventlistener.py new file mode 100644 index 0000000000..4d8228756f --- /dev/null +++ b/src/pretix/api/migrations/0003_webhook_webhookcall_webhookeventlistener.py @@ -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, + ), + ] diff --git a/src/pretix/api/models.py b/src/pretix/api/models.py index 7a62d0e66a..8723c53326 100644 --- a/src/pretix/api/models.py +++ b/src/pretix/api/models.py @@ -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",) diff --git a/src/pretix/api/serializers/webhooks.py b/src/pretix/api/serializers/webhooks.py new file mode 100644 index 0000000000..9298fb9e62 --- /dev/null +++ b/src/pretix/api/serializers/webhooks.py @@ -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 diff --git a/src/pretix/api/signals.py b/src/pretix/api/signals.py new file mode 100644 index 0000000000..341427ae18 --- /dev/null +++ b/src/pretix/api/signals.py @@ -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() diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 56be1365c5..40c10817e2 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -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) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 2baeb642e1..faf8fe1cf0 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -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({ diff --git a/src/pretix/api/views/webhooks.py b/src/pretix/api/views/webhooks.py new file mode 100644 index 0000000000..bcd2c970c9 --- /dev/null +++ b/src/pretix/api/views/webhooks.py @@ -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']) diff --git a/src/pretix/api/webhooks.py b/src/pretix/api/webhooks.py new file mode 100644 index 0000000000..f1a5240348 --- /dev/null +++ b/src/pretix/api/webhooks.py @@ -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 ''.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 diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index c052c42b20..75f9849775 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -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 diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 2f86753ac2..988d014502 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -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 diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py index 7494636cf8..285a2b3b18 100644 --- a/src/pretix/base/notifications.py +++ b/src/pretix/base/notifications.py @@ -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.') ), diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index f6c6dca8e2..06cb8cfaeb 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -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) diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py index 43e59654b8..090f4f3394 100644 --- a/src/pretix/base/services/notifications.py +++ b/src/pretix/base/services/notifications.py @@ -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 diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 6a8a8b1469..0070b2708f 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -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('{} – {}'.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' + }), + } diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index ea52f09ed6..6b3441344b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -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 diff --git a/src/pretix/control/templates/pretixcontrol/organizers/base.html b/src/pretix/control/templates/pretixcontrol/organizers/base.html index e4dfc1fb2a..5cc75579d2 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/base.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/base.html @@ -46,6 +46,13 @@ {% endif %} + {% if 'can_change_organizer_settings' in request.orgapermset %} +
  • + + {% trans "Webhooks" %} + +
  • + {% endif %} {% for nav in nav_organizer %}
  • diff --git a/src/pretix/control/templates/pretixcontrol/organizers/webhook_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/webhook_edit.html new file mode 100644 index 0000000000..cc817b6459 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/webhook_edit.html @@ -0,0 +1,24 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if webhook %} + {% trans "Modify webhook" %} + {% else %} + {% trans "Create a new webhook" %} + {% endif %} +
    + {% 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" %} +
    + +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/webhook_logs.html b/src/pretix/control/templates/pretixcontrol/organizers/webhook_logs.html new file mode 100644 index 0000000000..c925a106c5 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/webhook_logs.html @@ -0,0 +1,67 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% blocktrans with url=webhook.target_url %}Logs for webhook {{ url }}{% endblocktrans %} +

    + {% trans "This page shows all calls to your webhook in the past 30 days." %} +

    + {% for c in calls %} +
    + +
    +
    + {% if c.is_retry %} + + {% else %} + + {% endif %} + {{ c.datetime|date:"SHORT_DATETIME_FORMAT" }} +
    +
    + + {{ c.action_type }} +
    +
    + + {{ c.execution_time|floatformat:2 }}s +
    +
    + {% if c.success %} + + + {{ c.return_code }} + + {% else %} + {% if c.return_code %} + + + {{ c.return_code }} + + {% else %} + + + {% trans "Failed" %} + + {% endif %} + {% endif %} +
    +
    +
    +
    + +
    + {% trans "Request URL" %} +
    POST {{ c.target_url }}
    + {% trans "Request POST body" %} +
    {{ c.payload }}
    + {% trans "Response body" %} +
    {{ c.response_body }}
    +
    +
    +
    + {% empty %} +
    {% trans "This webhook did not receive any events in the last 30 days." %}
    + {% endfor %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/webhooks.html b/src/pretix/control/templates/pretixcontrol/organizers/webhooks.html new file mode 100644 index 0000000000..f9944d5699 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/webhooks.html @@ -0,0 +1,78 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + + {% trans "Webhooks" %} + +

    + {% blocktrans trimmed %} + This menu allows you to create webhooks to connect pretix to other online services. + {% endblocktrans %} +

    + {% if webhooks|length == 0 %} +
    + {% else %} +

    + {% trans "Create webhook" %} +

    +
    + + + + + + + + + + {% for w in webhooks %} + + + + + + {% endfor %} + +
    {% trans "Target URL" %}{% trans "Events" %}
    + {% if not w.enabled %}{% endif %} + {{ w.target_url }} + {% if not w.enabled %}{% endif %} + + {% if w.all_events %} + {% trans "All" %} + {% else %} +
      + {% for e in w.limit_events.all %} +
    • + + {{ e }} + +
    • + {% endfor %} +
    + {% endif %} +
    + + + + + + +
    +
    + {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index b9b04af196..f98cfb5f3e 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -71,6 +71,13 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'), url(r'^organizer/(?P[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), name='organizer.display'), + url(r'^organizer/(?P[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'), + url(r'^organizer/(?P[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(), + name='organizer.webhook.add'), + url(r'^organizer/(?P[^/]+)/webhook/(?P[^/]+)/edit$', organizer.WebHookUpdateView.as_view(), + name='organizer.webhook.edit'), + url(r'^organizer/(?P[^/]+)/webhook/(?P[^/]+)/logs$', organizer.WebHookLogsView.as_view(), + name='organizer.webhook.logs'), url(r'^organizer/(?P[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'), url(r'^organizer/(?P[^/]+)/device/add$', organizer.DeviceCreateView.as_view(), name='organizer.device.add'), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index e35a60e288..ddc78f649e 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -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.')) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 45d16c5c0d..9e956753ee 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -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') diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index 8cd87afa28..a74111acc9 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -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 ( diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 50077fb466..5f2242a188 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -68,6 +68,7 @@ def team(organizer): can_change_vouchers=True, can_view_vouchers=True, can_change_orders=True, + can_change_organizer_settings=True ) diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 4f59915e56..2e47171e98 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -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) diff --git a/src/tests/api/test_webhooks.py b/src/tests/api/test_webhooks.py new file mode 100644 index 0000000000..4c3551e1ac --- /dev/null +++ b/src/tests/api/test_webhooks.py @@ -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 diff --git a/src/tests/base/test_notifications.py b/src/tests/base/test_notifications.py index ff27c6e7f5..d1c713aaae 100644 --- a/src/tests/base/test_notifications.py +++ b/src/tests/base/test_notifications.py @@ -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 = [] diff --git a/src/tests/base/test_webhooks.py b/src/tests/base/test_webhooks.py new file mode 100644 index 0000000000..d472eaa12f --- /dev/null +++ b/src/tests/base/test_webhooks.py @@ -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 diff --git a/src/tests/control/test_checkins.py b/src/tests/control/test_checkins.py index c857bb8dd7..ae809982da 100644 --- a/src/tests/control/test_checkins.py +++ b/src/tests/control/test_checkins.py @@ -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() diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index ef58b98c25..bc74889b62 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -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), ] diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 93d2ca7b78..81c5c1f84f 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -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), diff --git a/src/tests/control/test_webhooks.py b/src/tests/control/test_webhooks.py new file mode 100644 index 0000000000..aa4e1c2b97 --- /dev/null +++ b/src/tests/control/test_webhooks.py @@ -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