API: Add transactions (#5292)

* API: Add transactions

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2025-07-08 14:11:53 +02:00
committed by GitHub
parent 177b9cdcbb
commit a381adac33
8 changed files with 576 additions and 4 deletions

View File

@@ -56,7 +56,7 @@ from pretix.base.models import (
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret,
PrintLog, RevokedTicketSecret, Transaction,
)
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
@@ -1783,3 +1783,23 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = BlockedTicketSecret
fields = ('id', 'secret', 'updated', 'blocked')
class TransactionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
class Meta:
model = Transaction
fields = (
"id", "order", "created", "datetime", "positionid", "count", "item", "variation",
"subevent", "price", "tax_rate", "tax_rule", "tax_code", "tax_value", "fee_type",
"internal_type"
)
class OrganizerTransactionSerializer(TransactionSerializer):
event = serializers.SlugRelatedField(source="order.event", slug_field="slug", read_only=True)
class Meta:
model = Transaction
fields = TransactionSerializer.Meta.fields + ("event",)

View File

@@ -66,6 +66,7 @@ orga_router.register(r'orders', order.OrganizerOrderViewSet)
orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -83,6 +84,7 @@ event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'transactions', order.TransactionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')

View File

@@ -57,9 +57,9 @@ from pretix.api.serializers.order import (
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
PrintLogSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer, TransactionSerializer,
)
from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer,
@@ -80,6 +80,7 @@ from pretix.base.models import (
)
from pretix.base.models.orders import (
BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret,
Transaction,
)
from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images
@@ -2030,3 +2031,61 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return BlockedTicketSecret.objects.filter(event=self.request.event)
with scopes_disabled():
class TransactionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
event = django_filters.CharFilter(field_name='order__event', lookup_expr='slug__iexact')
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
class Meta:
model = Transaction
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'subevent': ['exact', 'in'],
'tax_rule': ['exact', 'in'],
'tax_code': ['exact', 'in'],
'tax_rate': ['exact', 'in'],
'fee_type': ['exact', 'in'],
}
class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = TransactionSerializer
queryset = Transaction.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
ordering = ('datetime', 'pk')
ordering_fields = ('datetime', 'created', 'id',)
filterset_class = TransactionFilter
permission = 'can_view_orders'
def get_queryset(self):
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
class OrganizerTransactionViewSet(TransactionViewSet):
serializer_class = OrganizerTransactionSerializer
permission = None
def get_queryset(self):
qs = Transaction.objects.filter(
order__event__organizer=self.request.organizer
).select_related("order", "order__event")
if isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = qs.filter(
order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
)
elif self.request.user.is_authenticated:
qs = qs.filter(
order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
)
else:
raise PermissionDenied("Unknown authentication scheme")
return qs

View File

@@ -64,6 +64,8 @@ event_permission_sub_urls = [
('get', 'can_view_orders', 'revokedsecrets/1/', 404),
('get', 'can_view_orders', 'blockedsecrets/', 200),
('get', 'can_view_orders', 'blockedsecrets/1/', 404),
('get', 'can_view_orders', 'transactions/', 200),
('get', 'can_view_orders', 'transactions/1/', 404),
('get', 'can_view_orders', 'orders/', 200),
('get', 'can_view_orders', 'orderpositions/', 200),
('delete', 'can_change_orders', 'orderpositions/1/', 404),

View File

@@ -0,0 +1,254 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import copy
import datetime
import json
from decimal import Decimal
import freezegun
import pytest
from django_scopes import scopes_disabled
from pretix.base.models import Order, OrderPosition
from pretix.base.models.orders import OrderFee
@pytest.fixture
def item(event):
return event.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def taxrule(event):
return event.tax_rules.create(rate=Decimal("19.00"), code="S/standard")
@pytest.fixture
def order(event, item, device, taxrule):
with freezegun.freeze_time("2017-12-01T10:00:00"):
o = Order.objects.create(
code="FOO",
event=event,
email="dummy@dummy.test",
status=Order.STATUS_PENDING,
secret="k24fiuwvu8kxz3y1",
datetime=datetime.datetime(
2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc
),
expires=datetime.datetime(
2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc
),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=23,
locale="en",
)
o.fees.create(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=Decimal("0.25"),
tax_rate=Decimal("19.00"),
tax_value=Decimal("0.05"),
tax_rule=taxrule,
tax_code=taxrule.code,
)
OrderPosition.objects.create(
order=o,
item=item,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
pseudonymization_id="ABCDEFGHKL",
positionid=1,
)
o.create_transactions()
return o
TEST_TRANSACTION_RES_OP = {
"count": 1,
"created": "2017-12-01T10:00:00Z",
"datetime": "2017-12-01T10:00:00Z",
"fee_type": None,
"internal_type": None,
"item": None,
"order": "FOO",
"positionid": 1,
"price": "23.00",
"subevent": None,
"tax_code": None,
"tax_rate": "0.00",
"tax_rule": None,
"tax_value": "0.00",
"variation": None,
}
TEST_TRANSACTION_RES_FEE = {
"count": 1,
"created": "2017-12-01T10:00:00Z",
"datetime": "2017-12-01T10:00:00Z",
"fee_type": "payment",
"internal_type": "",
"item": None,
"order": "FOO",
"positionid": None,
"price": "0.25",
"subevent": None,
"tax_code": "S/standard",
"tax_rate": "19.00",
"tax_rule": 1,
"tax_value": "0.05",
"variation": None,
}
@pytest.mark.django_db
def test_transaction_list(token_client, organizer, event, order, item, taxrule):
res_op = copy.deepcopy(TEST_TRANSACTION_RES_OP)
res_fee = copy.deepcopy(TEST_TRANSACTION_RES_FEE)
with scopes_disabled():
res_fee["id"] = order.transactions.get(fee_type="payment").pk
res_fee["tax_rule"] = taxrule.pk
res_op["id"] = order.transactions.get(item__isnull=False).pk
res_op["item"] = item.pk
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/".format(
organizer.slug,
event.slug,
)
)
assert resp.status_code == 200
assert res_op in resp.data["results"]
assert res_fee in resp.data["results"]
assert resp.data["count"] == 2
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?order=FOO".format(
organizer.slug,
event.slug,
)
)
assert resp.data["count"] == 2
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?order=BAR".format(
organizer.slug,
event.slug,
)
)
assert resp.data["count"] == 0
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?datetime_since=2017-12-01T09:00:00Z".format(
organizer.slug,
event.slug,
)
)
assert resp.data["count"] == 2
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?datetime_since=2017-12-02T09:00:00Z".format(
organizer.slug,
event.slug,
)
)
assert resp.data["count"] == 0
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?item={}".format(
organizer.slug, event.slug, item.pk
)
)
assert resp.data["count"] == 1
assert res_op in resp.data["results"]
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/?fee_type={}".format(
organizer.slug, event.slug, "payment"
)
)
assert resp.data["count"] == 1
assert res_fee in resp.data["results"]
@pytest.mark.django_db
def test_order_detail(token_client, organizer, event, order, item, taxrule):
res_fee = copy.deepcopy(TEST_TRANSACTION_RES_FEE)
with scopes_disabled():
tx = order.transactions.get(fee_type="payment")
res_fee["id"] = tx.pk
res_fee["tax_rule"] = taxrule.pk
resp = token_client.get(
"/api/v1/organizers/{}/events/{}/transactions/{}/".format(
organizer.slug, event.slug, tx.pk
)
)
assert resp.status_code == 200
assert json.loads(json.dumps(res_fee)) == json.loads(json.dumps(resp.data))
@pytest.mark.django_db
def test_organizer_list(token_client, team, organizer, event, order, item, taxrule):
resp = token_client.get(
"/api/v1/organizers/{}/transactions/".format(
organizer.slug,
)
)
assert resp.status_code == 200
assert resp.data["count"] == 2
assert "event" in resp.data["results"][0]
resp = token_client.get(
"/api/v1/organizers/{}/transactions/?event=dummy".format(
organizer.slug,
)
)
assert resp.status_code == 200
assert resp.data["count"] == 2
resp = token_client.get(
"/api/v1/organizers/{}/transactions/?event=test".format(
organizer.slug,
)
)
assert resp.status_code == 200
assert resp.data["count"] == 0
team.all_events = False
team.save()
resp = token_client.get(
"/api/v1/organizers/{}/transactions/".format(
organizer.slug,
)
)
assert resp.status_code == 200
assert resp.data["count"] == 0
team.all_events = True
team.can_view_orders = False
team.save()
resp = token_client.get(
"/api/v1/organizers/{}/transactions/".format(
organizer.slug,
)
)
assert resp.status_code == 200
assert resp.data["count"] == 0