diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 91aebd933f..b59d208435 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1154,6 +1154,37 @@ Order position ticket download :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +Manipulating individual positions +--------------------------------- + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ + + Deletes an order position, identified by its internal ID. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the order position to delete + :statuscode 204: no error + :statuscode 400: This position cannot be deleted (e.g. last position in order) + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order position does not exist. + Order payment endpoints ----------------------- diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index bab0322bcd..3e283ac8f5 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -9,7 +9,7 @@ from django.http import FileResponse from django.shortcuts import get_object_or_404 from django.utils.timezone import make_aware, now from django_filters.rest_framework import DjangoFilterBackend, FilterSet -from rest_framework import serializers, status, viewsets +from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import detail_route from rest_framework.exceptions import ( APIException, NotFound, PermissionDenied, ValidationError, @@ -35,8 +35,8 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( - OrderError, cancel_order, extend_order, mark_order_expired, - mark_order_refunded, + OrderChangeManager, OrderError, cancel_order, extend_order, + mark_order_expired, mark_order_refunded, ) from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, @@ -340,7 +340,7 @@ class OrderPositionFilter(FilterSet): } -class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): +class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderPositionSerializer queryset = OrderPosition.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -348,6 +348,7 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) filterset_class = OrderPositionFilter permission = 'can_view_orders' + write_permission = 'can_change_orders' def get_queryset(self): return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related( @@ -388,6 +389,21 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): ) return resp + def perform_destroy(self, instance): + try: + ocm = OrderChangeManager( + instance.order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth, + notify=False + ) + ocm.cancel(instance) + ocm.commit() + except OrderError as e: + raise ValidationError(str(e)) + except Quota.QuotaExceededException as e: + raise ValidationError(str(e)) + class PaymentViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = OrderPaymentSerializer diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 10557354c6..1134606531 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -471,7 +471,8 @@ class Order(LoggedModel): def send_mail(self, subject: str, template: Union[str, LazyI18nString], context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', - user: User=None, headers: dict=None, sender: str=None, invoices: list=None): + user: User=None, headers: dict=None, sender: str=None, invoices: list=None, + auth=None): """ Sends an email to the user that placed this order. Basically, this method does two things: @@ -508,6 +509,7 @@ class Order(LoggedModel): self.log_action( log_entry_type, user=user, + auth=auth, data={ 'subject': subject, 'message': email_content, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 63b9ca792c..81763875e2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -607,9 +607,10 @@ class OrderChangeManager: SplitOperation = namedtuple('SplitOperation', ('position',)) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) - def __init__(self, order: Order, user, notify=True): + def __init__(self, order: Order, user=None, auth=None, notify=True): self.order = order self.user = user + self.auth = auth self.split_order = None self._committed = False self._totaldiff = 0 @@ -779,7 +780,7 @@ class OrderChangeManager: fee=None ) try: - p.confirm(send_mail=False, count_waitinglist=False, user=self.user) + p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth) except Quota.QuotaExceededException: raise OrderError(self.error_messages['paid_to_free_exceeded']) @@ -791,7 +792,7 @@ class OrderChangeManager: fee=None ) try: - p.confirm(send_mail=False, count_waitinglist=False, user=self.user) + p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth) except Quota.QuotaExceededException: raise OrderError(self.error_messages['paid_to_free_exceeded']) @@ -801,7 +802,7 @@ class OrderChangeManager: for op in self._operations: if isinstance(op, self.ItemOperation): - self.order.log_action('pretix.event.order.changed.item', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, 'old_item': op.position.item.pk, @@ -820,7 +821,7 @@ class OrderChangeManager: op.position.tax_rule = op.item.tax_rule op.position.save() elif isinstance(op, self.SubeventOperation): - self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, 'old_subevent': op.position.subevent.pk, @@ -835,7 +836,7 @@ class OrderChangeManager: op.position.tax_rule = op.position.item.tax_rule op.position.save() elif isinstance(op, self.PriceOperation): - self.order.log_action('pretix.event.order.changed.price', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, 'old_price': op.position.price, @@ -849,7 +850,7 @@ class OrderChangeManager: op.position.save() elif isinstance(op, self.CancelOperation): for opa in op.position.addons.all(): - self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': opa.pk, 'positionid': opa.positionid, 'old_item': opa.item.pk, @@ -857,7 +858,7 @@ class OrderChangeManager: 'addon_to': opa.addon_to_id, 'old_price': opa.price, }) - self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, 'old_item': op.position.item.pk, @@ -874,7 +875,7 @@ class OrderChangeManager: positionid=nextposid, subevent=op.subevent ) nextposid += 1 - self.order.log_action('pretix.event.order.changed.add', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ 'position': pos.pk, 'item': op.item.pk, 'variation': op.variation.pk if op.variation else None, @@ -890,7 +891,7 @@ class OrderChangeManager: op.position.save() CachedTicket.objects.filter(order_position__order=self.order).delete() CachedCombinedTicket.objects.filter(order=self.order).delete() - self.order.log_action('pretix.event.order.changed.secret', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={ 'position': op.position.pk, 'positionid': op.position.positionid, }) @@ -905,12 +906,12 @@ class OrderChangeManager: split_order.datetime = now() split_order.secret = generate_secret() split_order.save() - split_order.log_action('pretix.event.order.changed.split_from', user=self.user, data={ + split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={ 'original_order': self.order.code }) for op in split_positions: - self.order.log_action('pretix.event.order.changed.split', user=self.user, data={ + self.order.log_action('pretix.event.order.changed.split', user=self.user, auth=self.auth, data={ 'position': op.pk, 'positionid': op.positionid, 'old_item': op.item.pk, @@ -1080,7 +1081,7 @@ class OrderChangeManager: try: order.send_mail( email_subject, email_template, email_context, - 'pretix.event.order.email.order_changed', self.user + 'pretix.event.order.email.order_changed', self.user, auth=self.auth ) except SendMailException: logger.exception('Order changed email could not be sent') diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 293dc11d22..dfd3351ecc 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -672,6 +672,37 @@ def test_orderposition_detail(token_client, organizer, event, order, item, quest assert len(resp.data['downloads']) == 1 +@pytest.mark.django_db +def test_orderposition_delete(token_client, organizer, event, order, item, question): + op = order.positions.first() + resp = token_client.delete('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + )) + assert resp.status_code == 400 + assert resp.data == ['This operation would leave the order empty. Please cancel the order itself instead.'] + + op2 = OrderPosition.objects.create( + order=order, + item=item, + variation=None, + price=Decimal("23"), + attendee_name="Peter", + secret="foobar", + pseudonymization_id="BAZ", + ) + order.refresh_from_db() + assert order.total == Decimal('46') + assert order.positions.count() == 2 + + resp = token_client.delete('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op2.pk + )) + assert resp.status_code == 204 + assert order.positions.count() == 1 + order.refresh_from_db() + assert order.total == Decimal('23') + + @pytest.fixture def invoice(order): testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC) diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index d4c0ddf388..eeab272fa5 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -23,6 +23,7 @@ event_urls = [ event_permission_sub_urls = [ ('get', 'can_view_orders', 'orders/', 200), ('get', 'can_view_orders', 'orderpositions/', 200), + ('delete', 'can_change_orders', 'orderpositions/1/', 404), ('get', 'can_view_vouchers', 'vouchers/', 200), ('get', 'can_view_orders', 'invoices/', 200), ('get', 'can_view_orders', 'invoices/1/', 404),