From 99c257d39267e65a132aa2daf4898a4a0b7e5a52 Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Wed, 11 Feb 2026 12:51:09 +0100 Subject: [PATCH] adds webhooks for giftcards (Z#23205473) (#5834) * adds giftcard webhook events * maps issuer_id of giftcard to organizer_id for logging * adds new giftcard logtypes for transactions that aren't manual * log_action calls cleanup * drop acceptance webhook * add acceptor_id to the giftcard transaction webhook event * add missing log_action statements * add new webhooks to docs * fix tests * fix linting --- doc/api/resources/webhooks.rst | 3 ++ src/pretix/api/views/organizer.py | 25 +++++++---- src/pretix/api/webhooks.py | 41 +++++++++++++++++ src/pretix/base/models/base.py | 2 + src/pretix/base/payment.py | 15 +++++++ src/pretix/base/services/orders.py | 52 +++++++++++++++++++++- src/pretix/control/logdisplay.py | 2 + src/pretix/control/views/orders.py | 6 ++- src/pretix/control/views/organizer.py | 63 +++++++++++++++++++-------- 9 files changed, 179 insertions(+), 30 deletions(-) diff --git a/doc/api/resources/webhooks.rst b/doc/api/resources/webhooks.rst index 94dcc1b680..8568d50679 100644 --- a/doc/api/resources/webhooks.rst +++ b/doc/api/resources/webhooks.rst @@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core: * ``pretix.event.added`` * ``pretix.event.changed`` * ``pretix.event.deleted`` + * ``pretix.giftcards.created`` + * ``pretix.giftcards.modified`` + * ``pretix.giftcards.transaction.*`` * ``pretix.voucher.added`` * ``pretix.voucher.changed`` * ``pretix.voucher.deleted`` diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index f084a16794..cd066f2e83 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -249,12 +249,17 @@ class GiftCardViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): value = serializer.validated_data.pop('value') inst = serializer.save(issuer=self.request.organizer) - inst.transactions.create(value=value, acceptor=self.request.organizer) inst.log_action( - 'pretix.giftcards.transaction.manual', + action='pretix.giftcards.created', user=self.request.user, auth=self.request.auth, - data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + inst.transactions.create(value=value, acceptor=self.request.organizer) + inst.log_action( + action='pretix.giftcards.transaction.manual', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id}) ) @transaction.atomic() @@ -269,7 +274,7 @@ class GiftCardViewSet(viewsets.ModelViewSet): inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, testmode=serializer.instance.testmode) inst.log_action( - 'pretix.giftcards.modified', + action='pretix.giftcards.modified', user=self.request.user, auth=self.request.auth, data=self.request.data, @@ -282,10 +287,10 @@ class GiftCardViewSet(viewsets.ModelViewSet): diff = value - old_value inst.transactions.create(value=diff, acceptor=self.request.organizer) inst.log_action( - 'pretix.giftcards.transaction.manual', + action='pretix.giftcards.transaction.manual', user=self.request.user, auth=self.request.auth, - data={'value': diff} + data={'value': diff, 'acceptor_id': self.request.organizer.id} ) return inst @@ -309,10 +314,14 @@ class GiftCardViewSet(viewsets.ModelViewSet): }, status=status.HTTP_409_CONFLICT) gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer) gc.log_action( - 'pretix.giftcards.transaction.manual', + action='pretix.giftcards.transaction.manual', user=self.request.user, auth=self.request.auth, - data={'value': value, 'text': text} + data={ + 'value': value, + 'text': text, + 'acceptor_id': self.request.organizer.id + } ) return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) diff --git a/src/pretix/api/webhooks.py b/src/pretix/api/webhooks.py index ac3889157c..570448cd37 100644 --- a/src/pretix/api/webhooks.py +++ b/src/pretix/api/webhooks.py @@ -174,6 +174,35 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent): } +class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent): + def build_payload(self, logentry: LogEntry): + giftcard = logentry.content_object + if not giftcard: + return None + + return { + 'notification_id': logentry.pk, + 'issuer_id': logentry.organizer_id, + 'giftcard': giftcard.pk, + 'action': logentry.action_type, + } + + +class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent): + def build_payload(self, logentry: LogEntry): + giftcard = logentry.content_object + if not giftcard: + return None + + return { + 'notification_id': logentry.pk, + 'issuer_id': logentry.organizer_id, + 'acceptor_id': logentry.parsed_data.get('acceptor_id'), + 'giftcard': giftcard.pk, + 'action': logentry.action_type, + } + + class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent): def build_payload(self, logentry: LogEntry): @@ -433,6 +462,18 @@ def register_default_webhook_events(sender, **kwargs): 'pretix.customer.anonymized', _('Customer account anonymized'), ), + ParametrizedGiftcardWebhookEvent( + 'pretix.giftcards.created', + _('Gift card added'), + ), + ParametrizedGiftcardWebhookEvent( + 'pretix.giftcards.modified', + _('Gift card modified'), + ), + ParametrizedGiftcardTransactionWebhookEvent( + 'pretix.giftcards.transaction.*', + _('Gift card used in transcation'), + ) ) diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index 0a15dd1f9d..dd95ad03b0 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -130,6 +130,8 @@ class LoggingMixin: organizer_id = self.event.organizer_id elif hasattr(self, 'organizer_id'): organizer_id = self.organizer_id + elif hasattr(self, 'issuer_id'): + organizer_id = self.issuer_id if user and not user.is_authenticated: user = None diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index ab1fa0f9f0..cecf33d72f 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1646,6 +1646,13 @@ class GiftCardPayment(BasePaymentProvider): 'transaction_id': trans.pk, } payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case) + gc.log_action( + action='pretix.giftcards.transaction.payment', + data={ + 'value': trans.value, + 'acceptor_id': self.event.organizer.id + } + ) except PaymentException as e: payment.fail(info={'error': str(e)}) raise e @@ -1670,6 +1677,14 @@ class GiftCardPayment(BasePaymentProvider): 'transaction_id': trans.pk, } refund.done() + gc.log_action( + action='pretix.giftcards.transaction.refund', + data={ + 'value': refund.amount, + 'acceptor_id': self.event.organizer.id, + 'text': refund.comment, + } + ) @receiver(register_payment_providers, dispatch_uid="payment_free") diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index af01d01e55..f0f5a2984d 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -247,6 +247,15 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None for gc in position.issued_gift_cards.all(): gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer) + gc.log_action( + action='pretix.giftcards.transaction.manual', + user=user, + auth=auth, + data={ + 'value': position.price, + 'acceptor_id': order.event.organizer.id + } + ) break for m in position.granted_memberships.all(): @@ -548,6 +557,14 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device ) else: gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer) + gc.log_action( + action='pretix.giftcards.transaction.manual', + user=user, + data={ + 'value': -position.price, + 'acceptor_id': order.event.organizer.id, + } + ) for m in position.granted_memberships.all(): m.canceled = True @@ -2434,6 +2451,15 @@ class OrderChangeManager: )) else: gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer) + gc.log_action( + action='pretix.giftcards.transaction.manual', + user=self.user, + auth=self.auth, + data={ + 'value': -position.price, + 'acceptor_id': self.order.event.organizer.id + } + ) for m in position.granted_memberships.with_usages().all(): m.canceled = True @@ -2451,6 +2477,15 @@ class OrderChangeManager: )) else: gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer) + gc.log_action( + action='pretix.giftcards.transaction.manual', + user=self.user, + auth=self.auth, + data={ + 'value': -opa.position.price, + 'acceptor_id': self.order.event.organizer.id + } + ) for m in opa.granted_memberships.with_usages().all(): m.canceled = True @@ -3119,7 +3154,10 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial customer=order.customer, testmode=order.testmode ) - giftcard.log_action('pretix.giftcards.created', data={}) + giftcard.log_action( + action='pretix.giftcards.created', + data={} + ) r = order.refunds.create( order=order, payment=None, @@ -3406,7 +3444,17 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): currency=sender.currency, issued_in=p, testmode=order.testmode, expires=sender.organizer.default_gift_card_expiry, ) - gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer) + gc.log_action( + action='pretix.giftcards.created', + ) + trans = gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer) + gc.log_action( + action='pretix.giftcards.transaction.manual', + data={ + 'value': trans.value, + 'acceptor_id': order.event.organizer.id, + } + ) any_giftcards = True p.secret = gc.secret p.save(update_fields=['secret']) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index fb9aea035c..c12b866d0e 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -801,6 +801,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): 'pretix.giftcards.created': _('The gift card has been created.'), 'pretix.giftcards.modified': _('The gift card has been changed.'), 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), + 'pretix.giftcards.transaction.payment': _('A payment has been performed.'), + 'pretix.giftcards.transaction.refund': _('A refund has been performed. '), 'pretix.team.token.created': _('The token "{name}" has been created.'), 'pretix.team.token.deleted': _('The token "{name}" has been revoked.'), 'pretix.event.checkin.reset': _('The check-in and print log state has been reset.') diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index cf98be74a9..5b5ae7b092 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1226,7 +1226,11 @@ class OrderRefundView(OrderView): customer=order.customer, testmode=order.testmode ) - giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={}) + giftcard.log_action( + action='pretix.giftcards.created', + user=self.request.user, + data={} + ) refunds.append(OrderRefund( order=order, payment=None, diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 25977e9d8f..3582e37db1 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1667,9 +1667,12 @@ class GiftCardAcceptanceInviteView(OrganizerDetailViewMixin, OrganizerPermission active=False, ) self.request.organizer.log_action( - 'pretix.giftcards.acceptance.acceptor.invited', - data={'acceptor': form.cleaned_data['acceptor'].slug, - 'reusable_media': form.cleaned_data['reusable_media']}, + action='pretix.giftcards.acceptance.acceptor.invited', + data={ + 'acceptor': form.cleaned_data['acceptor'].slug, + 'issuer': self.request.organizer.slug, + 'reusable_media': form.cleaned_data['reusable_media'] + }, user=self.request.user ) messages.success(self.request, _('The selected organizer has been invited.')) @@ -1705,8 +1708,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe ).delete() if done: self.request.organizer.log_action( - 'pretix.giftcards.acceptance.acceptor.removed', - data={'acceptor': request.POST.get("delete_acceptor")}, + action='pretix.giftcards.acceptance.acceptor.removed', + data={ + 'acceptor': request.POST.get("delete_acceptor"), + 'issuer': self.request.organizer.slug + }, user=request.user ) messages.success(self.request, _('The selected connection has been removed.')) @@ -1716,8 +1722,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe ).delete() if done: self.request.organizer.log_action( - 'pretix.giftcards.acceptance.issuer.removed', - data={'issuer': request.POST.get("delete_acceptor")}, + action='pretix.giftcards.acceptance.issuer.removed', + data={ + 'issuer': request.POST.get("delete_acceptor"), + 'acceptor': self.request.organizer.slug + }, user=request.user ) messages.success(self.request, _('The selected connection has been removed.')) @@ -1727,8 +1736,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe ).update(active=True) if done: self.request.organizer.log_action( - 'pretix.giftcards.acceptance.issuer.accepted', - data={'issuer': request.POST.get("accept_issuer")}, + action='pretix.giftcards.acceptance.issuer.accepted', + data={ + 'issuer': request.POST.get("accept_issuer"), + 'acceptor': self.request.organizer.slug + }, user=request.user ) messages.success(self.request, _('The selected connection has been accepted.')) @@ -1834,10 +1846,11 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi acceptor=request.organizer, ) self.object.log_action( - 'pretix.giftcards.transaction.manual', + action='pretix.giftcards.transaction.manual', data={ 'value': value, - 'text': request.POST.get('text') + 'text': request.POST.get('text'), + 'acceptor_id': self.request.organizer.id }, user=self.request.user, ) @@ -1886,15 +1899,23 @@ class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi messages.success(self.request, _('The gift card has been created and can now be used.')) form.instance.issuer = self.request.organizer super().form_valid(form) - form.instance.transactions.create( - acceptor=self.request.organizer, - value=form.cleaned_data['value'] + form.instance.log_action( + action='pretix.giftcards.created', + user=self.request.user, ) - form.instance.log_action('pretix.giftcards.created', user=self.request.user, data={}) if form.cleaned_data['value']: - form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={ - 'value': form.cleaned_data['value'] - }) + form.instance.transactions.create( + acceptor=self.request.organizer, + value=form.cleaned_data['value'] + ) + form.instance.log_action( + action='pretix.giftcards.transaction.manual', + user=self.request.user, + data={ + 'value': form.cleaned_data['value'], + 'acceptor_id': self.request.organizer.id + } + ) return redirect(reverse( 'control:organizer.giftcard', kwargs={ @@ -1922,7 +1943,11 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi def form_valid(self, form): messages.success(self.request, _('The gift card has been changed.')) super().form_valid(form) - form.instance.log_action('pretix.giftcards.modified', user=self.request.user, data=dict(form.cleaned_data)) + form.instance.log_action( + action='pretix.giftcards.modified', + user=self.request.user, + data=dict(form.cleaned_data) + ) return redirect(reverse( 'control:organizer.giftcard', kwargs={