Compare commits

..

10 Commits

Author SHA1 Message Date
Lukas Bockstaller
7e3370fa89 fix linting 2026-01-29 10:59:29 +01:00
Lukas Bockstaller
9232f9e9fe fix tests 2026-01-29 10:58:27 +01:00
Lukas Bockstaller
c3ac143388 add new webhooks to docs 2026-01-23 17:21:16 +01:00
Lukas Bockstaller
7e61dbaefb add missing log_action statements 2026-01-23 11:55:39 +01:00
Lukas Bockstaller
aee76b094d add acceptor_id to the giftcard transaction webhook event 2026-01-22 13:39:26 +01:00
Lukas Bockstaller
ad78934ad2 drop acceptance webhook 2026-01-22 13:14:17 +01:00
Lukas Bockstaller
b5ba8c7304 log_action calls cleanup 2026-01-22 11:44:18 +01:00
Lukas Bockstaller
3aefd9f95e adds new giftcard logtypes for transactions that aren't manual 2026-01-22 11:35:56 +01:00
Lukas Bockstaller
98a54c6d21 maps issuer_id of giftcard to organizer_id for logging 2026-01-22 11:33:42 +01:00
Lukas Bockstaller
77d8a587fe adds giftcard webhook events 2026-01-22 11:33:03 +01:00
16 changed files with 182 additions and 86 deletions

View File

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

View File

@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end', 'location')
fields = ('id', 'start', 'end')
def validate(self, data):
data = super().validate(data)

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
from django.db import migrations, models
import i18nfield.fields
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0296_invoice_invoice_from_state"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]

View File

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

View File

@@ -2306,17 +2306,10 @@ class ItemProgramTime(models.Model):
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
:param location: venue
:type location: str
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:

View File

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

View File

@@ -248,6 +248,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():
@@ -558,6 +567,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
@@ -2461,6 +2478,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
@@ -2478,6 +2504,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
@@ -3131,7 +3166,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,
@@ -3414,7 +3452,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'])

View File

@@ -1354,10 +1354,6 @@ class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
self.fields['location'].widget.attrs['rows'] = '1'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center, Heidelberg, Germany'
)
class Meta:
model = ItemProgramTime
@@ -1365,7 +1361,6 @@ class ItemProgramTimeForm(I18nModelForm):
fields = [
'start',
'end',
'location'
]
field_classes = {
'start': forms.SplitDateTimeField,

View File

@@ -793,6 +793,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.')

View File

@@ -34,7 +34,6 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %}
{% bootstrap_field form.location layout="control" %}
</div>
</div>
{% endfor %}
@@ -60,7 +59,6 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
{% bootstrap_field formset.empty_form.location layout="control" %}
</div>
</div>
{% endescapescript %}

View File

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

View File

@@ -1669,9 +1669,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.'))
@@ -1707,8 +1710,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.'))
@@ -1718,8 +1724,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.'))
@@ -1729,8 +1738,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.'))
@@ -1836,10 +1848,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,
)
@@ -1888,15 +1901,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={
@@ -1924,7 +1945,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={

View File

@@ -153,7 +153,7 @@ def get_private_icals(event, positions):
# Actual ical organizer field is not useful since it will cause "your invitation was accepted" emails to the organizer
descr.append(_('Organizer: {organizer}').format(organizer=event.organizer.name))
description = '\n'.join(descr)
location = ", ".join(l.strip() for l in str(pt.location).splitlines() if l.strip())
location = None
dtstart = pt.start.astimezone(tz)
dtend = pt.end.astimezone(tz)
uid = 'pretix-{}-{}-{}-{}@{}'.format(

View File

@@ -1964,13 +1964,6 @@ def program_time2(item, category):
end=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc))
@pytest.fixture
def program_time3(item, category):
return item.program_times.create(start=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 31, 0, 0, 0, tzinfo=timezone.utc),
location='Testlocation')
TEST_PROGRAM_TIMES_RES = {
0: {
"start": "2017-12-27T00:00:00Z",
@@ -1979,37 +1972,24 @@ TEST_PROGRAM_TIMES_RES = {
1: {
"start": "2017-12-29T00:00:00Z",
"end": "2017-12-30T00:00:00Z",
},
2: {
"start": "2017-12-30T00:00:00Z",
"end": "2017-12-31T00:00:00Z",
"location": "Testlocation",
}
}
@pytest.mark.django_db
def test_program_times_list(token_client, organizer, event, item, program_time, program_time2, program_time3):
def test_program_times_list(token_client, organizer, event, item, program_time, program_time2):
res = dict(TEST_PROGRAM_TIMES_RES)
res[0]["id"] = program_time.pk
res[1]["id"] = program_time2.pk
res[2]["id"] = program_time3.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug,
item.pk))
assert resp.status_code == 200
assert res[0]['start'] == resp.data['results'][0]['start']
assert res[0]['end'] == resp.data['results'][0]['end']
assert res[0]['id'] == resp.data['results'][0]['id']
# assert res[0] == resp.data['results'][0]
assert res[1]['start'] == resp.data['results'][1]['start']
assert res[1]['end'] == resp.data['results'][1]['end']
assert res[1]['id'] == resp.data['results'][1]['id']
# assert res[1] == resp.data['results'][1]
assert res[2]['start'] == resp.data['results'][2]['start']
assert res[2]['end'] == resp.data['results'][2]['end']
assert res[2]['location'] == resp.data['results'][2]['location']
assert res[2]['id'] == resp.data['results'][2]['id']
assert res[2] == resp.data['results'][2]
@pytest.mark.django_db