API: Allow organizer-level access of orders and invoices (#3547)

This commit is contained in:
Raphael Michel
2023-08-28 16:54:42 +02:00
committed by GitHub
parent 5b819b76f0
commit 8d2224e725
9 changed files with 319 additions and 32 deletions
+59 -2
View File
@@ -12,6 +12,7 @@ The invoice resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
number string Invoice number (with prefix)
event string The slug of the parent event
order string Order code of the order this invoice belongs to
is_cancellation boolean ``true``, if this invoice is the cancellation of a
different invoice.
@@ -121,9 +122,13 @@ internal_reference string Customer's refe
The attribute ``lines.subevent`` has been added.
.. versionchanged:: 2023.8
Endpoints
---------
The ``event`` attribute has been added. The organizer-level endpoint has been added.
List of all invoices
--------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/
@@ -152,6 +157,7 @@ Endpoints
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -221,6 +227,50 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/invoices/
Returns a list of all invoices within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
...
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual invoices
----------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/
Returns information on one invoice, identified by its invoice number.
@@ -243,6 +293,7 @@ Endpoints
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
"is_cancellation": false,
"invoice_from_name": "Big Events LLC",
@@ -337,6 +388,12 @@ Endpoints
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Modifying invoices
------------------
Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one.
+49
View File
@@ -20,6 +20,7 @@ The order resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
code string Order code
event string The slug of the parent event
status string Order status, one of:
* ``n`` pending
@@ -130,6 +131,10 @@ last_modified datetime Last modificati
The ``valid_if_pending`` attribute has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. _order-position-resource:
@@ -289,6 +294,7 @@ List of all orders
"results": [
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
@@ -441,6 +447,48 @@ List of all orders
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/orders/
Returns a list of all orders within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event,
with the exception that the ``pdf_data`` parameter is not supported here.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/orders/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"code": "ABC12",
"event": "sampleconf",
...
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual orders
--------------------------
@@ -466,6 +514,7 @@ Fetching individual orders
{
"code": "ABC12",
"event": "sampleconf",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
+6 -4
View File
@@ -614,7 +614,7 @@ class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
'order': instance.order.code,
'secret': instance.order.secret,
'payment': instance.pk,
@@ -659,7 +659,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order):
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
'order': instance.code,
'secret': instance.secret,
})
@@ -694,6 +694,7 @@ class OrderListSerializer(serializers.ListSerializer):
class OrderSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True)
fees = OrderFeeSerializer(many=True, read_only=True)
@@ -709,7 +710,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
list_serializer_class = OrderListSerializer
fields = (
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending'
@@ -1593,6 +1594,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class InvoiceSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True)
lines = InlineInvoiceLineSerializer(many=True)
@@ -1601,7 +1603,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta:
model = Invoice
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
+3 -1
View File
@@ -61,6 +61,8 @@ orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'orders', order.OrganizerOrderViewSet)
orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
team_router = routers.DefaultRouter()
@@ -77,7 +79,7 @@ event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
+71 -25
View File
@@ -44,6 +44,7 @@ from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
from rest_framework.mixins import CreateModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
@@ -185,7 +186,7 @@ with scopes_disabled():
)
class OrderViewSet(viewsets.ModelViewSet):
class OrderViewSetMixin:
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
@@ -193,19 +194,12 @@ class OrderViewSet(viewsets.ModelViewSet):
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
return ctx
def get_base_queryset(self):
raise NotImplementedError()
def get_queryset(self):
qs = self.request.event.orders
qs = self.get_base_queryset()
if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
@@ -227,11 +221,12 @@ class OrderViewSet(viewsets.ModelViewSet):
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if request.query_params.get('pdf_data', 'false') == 'true':
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
prefetch_related_objects([request.organizer], 'meta_properties')
prefetch_related_objects(
[request.event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'),
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questions',
'item_meta_properties',
)
@@ -266,13 +261,12 @@ class OrderViewSet(viewsets.ModelViewSet):
)
)
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
ctx['pdf_data'] = False
return ctx
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
@@ -289,6 +283,45 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
def get_base_queryset(self):
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if isinstance(self.request.auth, (TeamAPIToken, Device)):
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm)
)
elif self.request.user.is_authenticated:
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm)
)
else:
raise PermissionDenied()
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
return ctx
def get_base_queryset(self):
return self.request.event.orders
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
@@ -1782,11 +1815,24 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
write_permission = 'can_change_orders'
def get_queryset(self):
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if getattr(self.request, 'event', None):
qs = self.request.event.invoices
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm)
)
elif self.request.user.is_authenticated:
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm)
)
return qs.prefetch_related('lines').select_related('order', 'refers').annotate(
nr=Concat('prefix', 'invoice_no')
)
@action(detail=True, )
@action(detail=True)
def download(self, request, **kwargs):
invoice = self.get_object()
@@ -1805,7 +1851,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return resp
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwarts):
def regenerate(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
@@ -1815,7 +1861,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer:
raise PermissionDenied('The invoice file has already been exported.')
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
elif now().astimezone(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1):
raise PermissionDenied('The invoice file is too old to be regenerated.')
else:
inv = regenerate_invoice(inv)
@@ -1830,7 +1876,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=204)
@action(detail=True, methods=['POST'])
def reissue(self, request, **kwarts):
def reissue(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
+69
View File
@@ -137,6 +137,37 @@ def order(event, item, taxrule, question):
return o
@pytest.fixture
def order2(event2, item2):
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='BAR', event=event2, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="asd436cvbfd1",
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),
total=23, locale='en'
)
o.payments.create(
provider='banktransfer',
state='pending',
amount=Decimal('23.00'),
)
OrderPosition.objects.create(
order=o,
item=item2,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="asdlfksdgdfgxcbfgdhfg",
pseudonymization_id="AC892345",
positionid=1,
)
return o
@pytest.fixture
def invoice(order):
testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc)
@@ -146,8 +177,18 @@ def invoice(order):
return generate_invoice(order)
@pytest.fixture
def invoice2(order2):
testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
return generate_invoice(order2)
TEST_INVOICE_RES = {
"order": "FOO",
"event": "dummy",
"number": "DUMMY-00001",
"is_cancellation": False,
"invoice_from_name": "",
@@ -268,6 +309,34 @@ def test_invoice_list(token_client, organizer, event, order, item, invoice):
assert [] == resp.data['results']
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2):
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number))
assert resp.status_code == 200
@pytest.mark.django_db
def test_invoice_detail(token_client, organizer, event, item, invoice):
res = dict(TEST_INVOICE_RES)
+1
View File
@@ -420,6 +420,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
pos = order.positions.first()
assert json.loads(json.dumps(resp.data)) == {
'order': 'FOO',
'event': 'dummy',
'number': 'DUMMY-00001',
'is_cancellation': False,
"invoice_from_name": "",
+1
View File
@@ -309,6 +309,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
del d['positions'][0]['secret']
assert d == {
'code': 'PREVIEW',
'event': 'dummy',
'status': 'n',
'testmode': False,
'email': 'dummy@dummy.test',
+60
View File
@@ -139,6 +139,37 @@ def order(event, item, taxrule, question):
return o
@pytest.fixture
def order2(event2, item2):
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='BAR', event=event2, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="asd436cvbfd1",
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),
total=23, locale='en'
)
o.payments.create(
provider='banktransfer',
state='pending',
amount=Decimal('23.00'),
)
OrderPosition.objects.create(
order=o,
item=item2,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="asdlfksdgdfgxcbfgdhfg",
pseudonymization_id="AC892345",
positionid=1,
)
return o
@pytest.fixture
def clist_autocheckin(event):
c = event.checkin_lists.create(name="Default", all_products=True, auto_checkin_sales_channels=['web'])
@@ -228,6 +259,7 @@ TEST_REFUNDS_RES = [
]
TEST_ORDER_RES = {
"code": "FOO",
"event": "dummy",
"status": "n",
"testmode": False,
"secret": "k24fiuwvu8kxz3y1",
@@ -460,6 +492,34 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques
assert len(resp.data['fees']) == 2
@pytest.mark.django_db
def test_organizer_level(token_client, organizer, team, event, event2, order, order2):
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 2
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 200
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
with scopes_disabled():
team.all_events = False
team.save()
team.limit_events.set([event2])
resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug))
assert resp.status_code == 200
assert len(resp.data['results']) == 1
resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug))
assert resp.status_code == 404
resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug))
assert resp.status_code == 200
@pytest.mark.django_db
def test_include_exclude_fields(token_client, organizer, event, order, item, taxrule, question):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?exclude=positions.secret'.format(