diff --git a/app/eventyay/api/views/order.py b/app/eventyay/api/views/order.py index ed2491d49e..76102689e7 100644 --- a/app/eventyay/api/views/order.py +++ b/app/eventyay/api/views/order.py @@ -407,6 +407,65 @@ def mark_canceled(self, request, **kwargs): return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return self.retrieve(request, [], **kwargs) + @action(detail=False, methods=['POST'], url_path='create_onsite') + def create_onsite(self, request, **kwargs): + """ + Create a new order for on-site sales (box office). + + This endpoint is restricted to staff with can_change_orders permission + (enforced via OrderViewSet.write_permission). + + Orders are created as paid with manual payment provider, assuming payment + (cash/card) has been completed at the counter. This matches the behavior + of existing order import and box-office workflows. + """ + data = request.data.copy() + + # Force box-office semantics - do not allow client overrides + # Payment is assumed to be completed at the counter (cash/manual) + data['payment_provider'] = 'manual' + data['status'] = 'p' # Paid + data['sales_channel'] = 'box_office' # Always box office + data['send_email'] = False # Never send email for on-site orders + + serializer = OrderCreateSerializer(data=data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + try: + self.perform_create(serializer) + except TaxRule.SaleNotAllowed: + raise ValidationError(_('One of the selected products is not available in the selected country.')) + + order = serializer.instance + if not order.pk: + raise ValidationError(_('Order creation failed.')) + + serializer_output = OrderSerializer(order, context=serializer.context) + + order.log_action( + 'eventyay.event.order.placed', + user=request.user if request.user.is_authenticated else None, + auth=request.auth, + data={'source': 'onsite_api'}, + ) + + with language(order.locale, self.request.event.settings.region): + order_placed.send(self.request.event, order=order) + if order.status == Order.STATUS_PAID: + order_paid.send(self.request.event, order=order) + + return Response( + { + 'order_id': order.pk, + 'code': order.code, + 'status': order.status, + 'total': str(order.total), + 'order': serializer_output.data, + }, + status=status.HTTP_201_CREATED, + ) + @action(detail=True, methods=['POST']) def reactivate(self, request, **kwargs): order = self.get_object() diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index e8323e18d6..5947171306 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -4918,3 +4918,132 @@ def test_position_update_question_handling(token_client, organizer, event, order answ = op.answers.get() assert answ.file assert answ.answer.startswith('file://') + + +@pytest.mark.django_db +def test_order_create_onsite(token_client, organizer, event, item, quota, question): + res = { + 'email': 'onsite@dummy.test', + 'locale': 'en', + 'positions': [ + { + 'item': item.pk, + 'variation': None, + 'price': '23.00', + 'attendee_name_parts': {'full_name': 'John Doe'}, + 'attendee_email': 'john@example.com', + 'answers': [{'question': question.pk, 'answer': 'M', 'options': []}], + 'subevent': None, + } + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/create_onsite/'.format(organizer.slug, event.slug), + format='json', + data=res, + ) + assert resp.status_code == 201 + assert resp.data['status'] == 'p' + assert resp.data['code'] + assert resp.data['order_id'] + assert resp.data['total'] == '23.00' + + with scopes_disabled(): + o = Order.objects.get(pk=resp.data['order_id']) + assert o.status == Order.STATUS_PAID + assert o.email == 'onsite@dummy.test' + assert o.total == Decimal('23.00') + assert o.positions.count() == 1 + assert o.sales_channel == 'box_office' + + payment = o.payments.filter(provider='manual', state=OrderPayment.PAYMENT_STATE_CONFIRMED).first() + assert payment is not None + assert payment.amount == o.total + + +@pytest.mark.django_db +def test_order_create_onsite_without_permission(client, organizer, event, item, quota, question): + res = { + 'email': 'onsite@dummy.test', + 'locale': 'en', + 'positions': [ + { + 'item': item.pk, + 'variation': None, + 'price': '23.00', + 'attendee_name_parts': {'full_name': 'John Doe'}, + 'subevent': None, + } + ], + } + resp = client.post( + '/api/v1/organizers/{}/events/{}/orders/create_onsite/'.format(organizer.slug, event.slug), + format='json', + data=res, + ) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_order_create_onsite_authenticated_without_can_change_orders(user_client, organizer, event, item, quota, question, team): + team.can_change_orders = False + team.save() + + res = { + 'email': 'onsite@dummy.test', + 'locale': 'en', + 'positions': [ + { + 'item': item.pk, + 'variation': None, + 'price': '23.00', + 'attendee_name_parts': {'full_name': 'John Doe'}, + 'subevent': None, + } + ], + } + resp = user_client.post( + '/api/v1/organizers/{}/events/{}/orders/create_onsite/'.format(organizer.slug, event.slug), + format='json', + data=res, + ) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_order_create_onsite_authenticated_with_can_change_orders(user_client, organizer, event, item, quota, question, team): + team.can_change_orders = True + team.save() + + res = { + 'email': 'onsite@dummy.test', + 'locale': 'en', + 'positions': [ + { + 'item': item.pk, + 'variation': None, + 'price': '23.00', + 'attendee_name_parts': {'full_name': 'John Doe'}, + 'attendee_email': 'john@example.com', + 'answers': [{'question': question.pk, 'answer': 'M', 'options': []}], + 'subevent': None, + } + ], + } + resp = user_client.post( + '/api/v1/organizers/{}/events/{}/orders/create_onsite/'.format(organizer.slug, event.slug), + format='json', + data=res, + ) + assert resp.status_code == 201 + assert resp.data['status'] == 'p' + assert resp.data['code'] + assert resp.data['order_id'] + + with scopes_disabled(): + o = Order.objects.get(pk=resp.data['order_id']) + assert o.status == Order.STATUS_PAID + assert o.sales_channel == 'box_office' + + payment = o.payments.filter(provider='manual', state=OrderPayment.PAYMENT_STATE_CONFIRMED).first() + assert payment is not None