diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index b593c6e895..13540e89a4 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -60,6 +60,7 @@ that your clients can deal with them properly: * Support of new HTTP methods for a given API endpoint * Support of new query parameters for a given API endpoint * New fields contained in API responses +* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes) We treat the following types of changes as *backwards-incompatible*: diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index d2dfd5a884..063a24f47d 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -68,6 +68,7 @@ positions list of objects List of order p non-canceled positions are included. fees list of objects List of fees included in the order total. By default, only non-canceled fees are included. +├ id integer Internal ID of the fee record ├ fee_type string Type of fee (currently ``payment``, ``passbook``, ``other``) ├ value money (string) Fee amount @@ -136,6 +137,10 @@ last_modified datetime Last modificati The ``subevent`` query parameters has been added. +.. versionchanged:: 4.8 + + The ``order.fees.id`` attribute has been added. + .. _order-position-resource: @@ -735,6 +740,37 @@ Generating new secrets :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/ + + Triggers generation of a new ``secret`` attribute for a single order position. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/regenerate_secrets/ 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 + + (Full order position resource, see above.) + + :param organizer: The ``slug`` field of the organizer of the event + :param event: The ``slug`` field of the event + :param code: The ``id`` field of the order position to update + + :statuscode 200: no error + :statuscode 400: The order position could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position. + Deleting orders --------------- @@ -1642,6 +1678,8 @@ 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. +.. _rest-orderpositions-manipulate: + Manipulating individual positions --------------------------------- @@ -1649,6 +1687,11 @@ Manipulating individual positions The ``PATCH`` method has been added for individual positions. +.. versionchanged:: 4.8 + + The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules. + The ``POST`` endpoint to add individual positions has been added. + .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ Updates specific fields on an order position. Currently, only the following fields are supported: @@ -1675,6 +1718,21 @@ Manipulating individual positions and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value ``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it. + * ``item`` + + * ``variation`` + + * ``subevent`` + + * ``seat`` (specified as a string mapping to a ``string_guid``) + + * ``price`` + + * ``tax_rule`` + + Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice, + you need to take care of that yourself. + **Example request**: .. sourcecode:: http @@ -1696,7 +1754,7 @@ Manipulating individual positions Vary: Accept Content-Type: application/json - (Full order resource, see above.) + (Full order position resource, see above.) :param organizer: The ``slug`` field of the organizer of the event :param event: The ``slug`` field of the event @@ -1735,6 +1793,230 @@ Manipulating individual positions :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. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ + + Adds a new position to an order. Currently, only the following fields are supported: + + * ``order`` (mandatory, specified as a string mapping to a ``code``) + + * ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position) + + * ``item`` (mandatory) + + * ``variation`` (mandatory depending on item) + + * ``subevent`` (mandatory depending on event) + + * ``seat`` (specified as a string mapping to a ``string_guid``, mandatory depending on event and item) + + * ``price`` (default price will be used if unset) + + * ``attendee_email`` + + * ``attendee_name_parts`` or ``attendee_name`` + + * ``company`` + + * ``street`` + + * ``zipcode`` + + * ``city`` + + * ``country`` + + * ``state`` + + * ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore + expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier`` + and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value + ``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it. + + This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "order": "ABC12", + "item": 5, + "addon_to": 1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + (Full order position resource, see above.) + + :param organizer: The ``slug`` field of the organizer of the event + :param event: The ``slug`` field of the event + + :statuscode 200: no error + :statuscode 400: The position could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this position. + +.. 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. + +Changing order contents +----------------------- + +While you can :ref:`change positions individually ` sometimes it is necessary to make +multiple changes to an order at once within one transaction. This makes it possible to e.g. swap the seats of two +attendees in an order without running into conflicts. This interface also offers some possibilities not available +otherwise, such as splitting an order or changing fees. + +.. versionchanged:: 4.8 + + This endpoint has been added to the system. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/ + + Performs a change operation on an order. You can supply the following fields: + + * ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and + ``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``, + ``price``, ``tax_rule``). + + * ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID. + + * ``split_positions``: A list of objects with the single key ``position`` specifying an order position ID. + + * ``create_positions``: A list of objects describing new order positions with the same fields supported as when + creating them individually through the ``POST …/orderpositions/`` endpoint. + + * ``patch_fees``: A list of objects with the two keys ``fee`` specifying an order fee ID and + ``body`` specifying the desired changed values of the position (``value``). + + * ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID. + + * ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice + address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null`` + (the default) the taxes are not recalculated. + + * ``send_email``: If set to ``true``, the customer will be notified about the change. Defaults to ``false``. + + * ``reissue_invoice``: If set to ``true`` and an invoice exists for the order, it will be canceled and a new invoice + will be issued. Defaults to ``true``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "cancel_positions": [ + { + "position": 12373 + } + ], + "patch_positions": [ + { + "position": 12374, + "body": { + "item": 12, + "variation": None, + "subevent": 562, + "seat": "seat-guid-2", + "price": "99.99", + "tax_rule": 15 + } + } + ], + "split_positions": [ + { + "position": 12375 + } + ], + "create_positions": [ + { + "item": 12, + "variation": None, + "subevent": 562, + "seat": "seat-guid-2", + "price": "99.99", + "addon_to": 12374, + "attendee_name": "Peter", + } + ], + "cancel_fees": [ + { + "fee": 49 + } + ], + "change_fees": [ + { + "fee": 51, + "body": { + "value": "12.00" + } + } + ], + "reissue_invoice": true, + "send_email": true, + "recalculate_taxes": "keep_gross" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + (Full order position resource, see above.) + + :param organizer: The ``slug`` field of the organizer of the event + :param event: The ``slug`` field of the event + :param code: The ``code`` field of the order to update + + :statuscode 200: no error + :statuscode 400: The order could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. + Order payment endpoints ----------------------- diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index ab30b07523..028a8ad2f5 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -424,88 +424,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer): self.fields.pop('pdf_data', None) def validate(self, data): - if data.get('attendee_name') and data.get('attendee_name_parts'): - raise ValidationError( - {'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']} - ) - if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'): - data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme - - if data.get('country'): - if not pycountry.countries.get(alpha_2=data.get('country').code): - raise ValidationError( - {'country': ['Invalid country code.']} - ) - - if data.get('state'): - cc = str(data.get('country') or self.instance.country or '') - if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: - raise ValidationError( - {'state': ['States are not supported in country "{}".'.format(cc)]} - ) - if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')): - raise ValidationError( - {'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]} - ) - return data - - def update(self, instance, validated_data): - # Even though all fields that shouldn't be edited are marked as read_only in the serializer - # (hopefully), we'll be extra careful here and be explicit about the model fields we update. - update_fields = [ - 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country', - 'state', 'attendee_email', - ] - answers_data = validated_data.pop('answers', None) - - name = validated_data.pop('attendee_name', '') - if name and not validated_data.get('attendee_name_parts'): - validated_data['attendee_name_parts'] = { - '_legacy': name - } - - for attr, value in validated_data.items(): - if attr in update_fields: - setattr(instance, attr, value) - - instance.save(update_fields=update_fields) - - if answers_data is not None: - qs_seen = set() - answercache = { - a.question_id: a for a in instance.answers.all() - } - for answ_data in answers_data: - options = answ_data.pop('options', []) - if answ_data['question'].pk in qs_seen: - raise ValidationError(f'Question {answ_data["question"]} was sent twice.') - if answ_data['question'].pk in answercache: - a = answercache[answ_data['question'].pk] - if isinstance(answ_data['answer'], File): - a.file.save(answ_data['answer'].name, answ_data['answer'], save=False) - a.answer = 'file://' + a.file.name - elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep": - pass # keep current file - else: - for attr, value in answ_data.items(): - setattr(a, attr, value) - a.save() - else: - if isinstance(answ_data['answer'], File): - an = answ_data.pop('answer') - a = instance.answers.create(**answ_data, answer='') - a.file.save(os.path.basename(an.name), an, save=False) - a.answer = 'file://' + a.file.name - a.save() - else: - a = instance.answers.create(**answ_data) - a.options.set(options) - qs_seen.add(a.question_id) - for qid, a in answercache.items(): - if qid not in qs_seen: - a.delete() - - return instance + raise TypeError("this serializer is readonly") class RequireAttentionField(serializers.Field): @@ -593,7 +512,7 @@ class OrderPaymentDateField(serializers.DateField): class OrderFeeSerializer(I18nAwareModelSerializer): class Meta: model = OrderFee - fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled') + fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled') class PaymentURLField(serializers.URLField): @@ -1361,14 +1280,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer): f.order = order._wrapped if simulate else order f._calculate_tax() fees.append(f) - if not simulate: + if simulate: + f.id = 0 + else: f.save() else: f = OrderFee(**fee_data) f.order = order._wrapped if simulate else order f._calculate_tax() fees.append(f) - if not simulate: + if simulate: + f.id = 0 + else: f.save() order.total += sum([f.value for f in fees]) diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py new file mode 100644 index 0000000000..fd66a2aea5 --- /dev/null +++ b/src/pretix/api/serializers/orderchange.py @@ -0,0 +1,424 @@ +# +# 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 . +# +# 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 +# . +# +import logging +import os + +import pycountry +from django.core.files import File +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from pretix.api.serializers.order import ( + AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField, + OrderPositionCreateSerializer, +) +from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition +from pretix.base.services.orders import OrderError +from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS + +logger = logging.getLogger(__name__) + + +class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer): + order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False) + answers = AnswerCreateSerializer(many=True, required=False) + addon_to = serializers.IntegerField(required=False, allow_null=True) + secret = serializers.CharField(required=False) + attendee_name = serializers.CharField(required=False, allow_null=True) + seat = serializers.CharField(required=False, allow_null=True) + price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2, + max_digits=10) + country = CompatibleCountryField(source='*') + + class Meta: + model = OrderPosition + fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', + 'company', 'street', 'zipcode', 'city', 'country', 'state', + 'secret', 'addon_to', 'subevent', 'answers', 'seat') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.context: + return + self.fields['order'].queryset = self.context['event'].orders.all() + self.fields['item'].queryset = self.context['event'].items.all() + self.fields['subevent'].queryset = self.context['event'].subevents.all() + self.fields['seat'].queryset = self.context['event'].seats.all() + self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event']) + if 'order' in self.context: + del self.fields['order'] + + def validate(self, data): + data = super().validate(data) + if data.get('addon_to'): + try: + data['addon_to'] = data['order'].positions.get(positionid=data['addon_to']) + except OrderPosition.DoesNotExist: + raise ValidationError({ + 'addon_to': ['addon_to refers to an unknown position ID for this order.'] + }) + return data + + def create(self, validated_data): + ocm = self.context['ocm'] + + try: + ocm.add_position( + item=validated_data['item'], + variation=validated_data.get('variation'), + price=validated_data.get('price'), + addon_to=validated_data.get('addon_to'), + subevent=validated_data.get('subevent'), + seat=validated_data.get('seat'), + ) + if self.context.get('commit', True): + ocm.commit() + return validated_data['order'].positions.order_by('-positionid').first() + else: + return OrderPosition() # fake to appease DRF + except OrderError as e: + raise ValidationError(str(e)) + + +class OrderPositionInfoPatchSerializer(serializers.ModelSerializer): + answers = AnswerSerializer(many=True) + country = CompatibleCountryField(source='*') + attendee_name = serializers.CharField(required=False) + + class Meta: + model = OrderPosition + fields = ( + 'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country', + 'state', 'attendee_email', 'answers', + ) + + def validate(self, data): + if data.get('attendee_name') and data.get('attendee_name_parts'): + raise ValidationError( + {'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']} + ) + if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'): + data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme + + if data.get('country'): + if not pycountry.countries.get(alpha_2=data.get('country').code): + raise ValidationError( + {'country': ['Invalid country code.']} + ) + + if data.get('state'): + cc = str(data.get('country') or self.instance.country or '') + if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: + raise ValidationError( + {'state': ['States are not supported in country "{}".'.format(cc)]} + ) + if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')): + raise ValidationError( + {'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]} + ) + return data + + def update(self, instance, validated_data): + answers_data = validated_data.pop('answers', None) + + name = validated_data.pop('attendee_name', '') + if name and not validated_data.get('attendee_name_parts'): + validated_data['attendee_name_parts'] = { + '_legacy': name + } + + for attr, value in validated_data.items(): + if attr in self.fields: + setattr(instance, attr, value) + + instance.save(update_fields=list(validated_data.keys())) + + if answers_data is not None: + qs_seen = set() + answercache = { + a.question_id: a for a in instance.answers.all() + } + for answ_data in answers_data: + options = answ_data.pop('options', []) + if answ_data['question'].pk in qs_seen: + raise ValidationError(f'Question {answ_data["question"]} was sent twice.') + if answ_data['question'].pk in answercache: + a = answercache[answ_data['question'].pk] + if isinstance(answ_data['answer'], File): + a.file.save(answ_data['answer'].name, answ_data['answer'], save=False) + a.answer = 'file://' + a.file.name + elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep": + pass # keep current file + else: + for attr, value in answ_data.items(): + setattr(a, attr, value) + a.save() + else: + if isinstance(answ_data['answer'], File): + an = answ_data.pop('answer') + a = instance.answers.create(**answ_data, answer='') + a.file.save(os.path.basename(an.name), an, save=False) + a.answer = 'file://' + a.file.name + a.save() + else: + a = instance.answers.create(**answ_data) + a.options.set(options) + qs_seen.add(a.question_id) + for qid, a in answercache.items(): + if qid not in qs_seen: + a.delete() + + return instance + + +class OrderPositionChangeSerializer(serializers.ModelSerializer): + seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False) + + class Meta: + model = OrderPosition + fields = ( + 'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.context: + return + self.fields['item'].queryset = self.context['event'].items.all() + self.fields['subevent'].queryset = self.context['event'].subevents.all() + self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all() + if kwargs.get('partial'): + for k, v in self.fields.items(): + self.fields[k].required = False + + def validate_item(self, item): + if item.event != self.context['event']: + raise ValidationError( + 'The specified item does not belong to this event.' + ) + return item + + def validate_subevent(self, subevent): + if self.context['event'].has_subevents: + if not subevent: + raise ValidationError( + 'You need to set a subevent.' + ) + if subevent.event != self.context['event']: + raise ValidationError( + 'The specified subevent does not belong to this event.' + ) + elif subevent: + raise ValidationError( + 'You cannot set a subevent for this event.' + ) + return subevent + + def validate(self, data, instance=None): + instance = instance or self.instance + if instance is None: + return data # needs to be done later + if data.get('item', instance.item): + if data.get('item', instance.item).has_variations: + if not data.get('variation', instance.variation): + raise ValidationError({'variation': ['You should specify a variation for this item.']}) + else: + if data.get('variation', instance.variation).item != data.get('item', instance.item): + raise ValidationError( + {'variation': ['The specified variation does not belong to the specified item.']} + ) + elif data.get('variation', instance.variation): + raise ValidationError( + {'variation': ['You cannot specify a variation for this item.']} + ) + + return data + + def update(self, instance, validated_data): + ocm = self.context['ocm'] + current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None + item = validated_data.get('item', instance.item) + variation = validated_data.get('variation', instance.variation) + subevent = validated_data.get('subevent', instance.subevent) + price = validated_data.get('price', instance.price) + seat = validated_data.get('seat', current_seat) + tax_rule = validated_data.get('tax_rule', instance.tax_rule) + + change_item = None + if item != instance.item or variation != instance.variation: + change_item = (item, variation) + + change_subevent = None + if self.context['event'].has_subevents and subevent != instance.subevent: + change_subevent = (subevent,) + + try: + if change_item is not None and change_subevent is not None: + ocm.change_item_and_subevent(instance, *change_item, *change_subevent) + elif change_item is not None: + ocm.change_item(instance, *change_item) + elif change_subevent is not None: + ocm.change_subevent(instance, *change_subevent) + + if seat != current_seat or change_subevent: + ocm.change_seat(instance, seat['seat_guid'] if seat else None) + + if price != instance.price: + ocm.change_price(instance, price) + + if tax_rule != instance.tax_rule: + ocm.change_tax_rule(instance, tax_rule) + + if self.context.get('commit', True): + ocm.commit() + instance.refresh_from_db() + except OrderError as e: + raise ValidationError(str(e)) + return instance + + +class PatchPositionSerializer(serializers.Serializer): + position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none()) + + def validate_position(self, value): + self.fields['body'].instance = value # hack around DRFs validation order + return value + + def validate(self, data): + OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position']) + return data + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['position'].queryset = self.context['order'].positions.all() + self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True) + + +class SelectPositionSerializer(serializers.Serializer): + position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['position'].queryset = self.context['order'].positions.all() + + +class OrderFeeChangeSerializer(serializers.ModelSerializer): + + class Meta: + model = OrderFee + fields = ( + 'value', + ) + + def update(self, instance, validated_data): + ocm = self.context['ocm'] + value = validated_data.get('value', instance.value) + + try: + if value != instance.value: + ocm.change_fee(instance, value) + + if self.context.get('commit', True): + ocm.commit() + instance.refresh_from_db() + except OrderError as e: + raise ValidationError(str(e)) + return instance + + +class PatchFeeSerializer(serializers.Serializer): + fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['fee'].queryset = self.context['order'].fees.all() + self.fields['body'] = OrderFeeChangeSerializer(context=self.context) + + +class SelectFeeSerializer(serializers.Serializer): + fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.context: + return + self.fields['fee'].queryset = self.context['order'].fees.all() + + +class OrderChangeOperationSerializer(serializers.Serializer): + send_email = serializers.BooleanField(default=False, required=False) + reissue_invoice = serializers.BooleanField(default=True, required=False) + recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[ + ('keep_net', 'keep_net'), + ('keep_gross', 'keep_gross'), + ]) + + def __init__(self, *args, **kwargs): + super().__init__(self, *args, **kwargs) + self.fields['patch_positions'] = PatchPositionSerializer( + many=True, required=False, context=self.context + ) + self.fields['cancel_positions'] = SelectPositionSerializer( + many=True, required=False, context=self.context + ) + self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer( + many=True, required=False, context=self.context + ) + self.fields['split_positions'] = SelectPositionSerializer( + many=True, required=False, context=self.context + ) + self.fields['patch_fees'] = PatchFeeSerializer( + many=True, required=False, context=self.context + ) + self.fields['cancel_fees'] = SelectFeeSerializer( + many=True, required=False, context=self.context + ) + + def validate(self, data): + seen_positions = set() + for d in data.get('patch_positions', []): + print(d, seen_positions) + if d['position'] in seen_positions: + raise ValidationError({'patch_positions': ['You have specified the same object twice.']}) + seen_positions.add(d['position']) + seen_positions = set() + for d in data.get('cancel_positions', []): + if d['position'] in seen_positions: + raise ValidationError({'cancel_positions': ['You have specified the same object twice.']}) + seen_positions.add(d['position']) + seen_positions = set() + for d in data.get('split_positions', []): + if d['position'] in seen_positions: + raise ValidationError({'split_positions': ['You have specified the same object twice.']}) + seen_positions.add(d['position']) + seen_fees = set() + for d in data.get('patch_fees', []): + if d['fee'] in seen_fees: + raise ValidationError({'patch_fees': ['You have specified the same object twice.']}) + seen_positions.add(d['fee']) + seen_fees = set() + for d in data.get('cancel_fees', []): + if d['fee'] in seen_fees: + raise ValidationError({'cancel_fees': ['You have specified the same object twice.']}) + seen_positions.add(d['fee']) + + return data diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index b6700141de..a1e8da2344 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -36,7 +36,7 @@ from django.utils.translation import gettext as _ from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from PIL import Image -from rest_framework import mixins, serializers, status, viewsets +from rest_framework import serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import ( APIException, NotFound, PermissionDenied, ValidationError, @@ -53,6 +53,12 @@ from pretix.api.serializers.order import ( PriceCalcSerializer, RevokedTicketSecretSerializer, SimulatedOrderSerializer, ) +from pretix.api.serializers.orderchange import ( + OrderChangeOperationSerializer, OrderFeeChangeSerializer, + OrderPositionChangeSerializer, + OrderPositionCreateForExistingOrderSerializer, + OrderPositionInfoPatchSerializer, +) from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice, @@ -782,6 +788,79 @@ class OrderViewSet(viewsets.ModelViewSet): with transaction.atomic(): self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + @action(detail=True, methods=['POST']) + def change(self, request, **kwargs): + order = self.get_object() + + serializer = OrderChangeOperationSerializer( + context={'order': order, **self.get_serializer_context()}, + data=request.data, + ) + serializer.is_valid(raise_exception=True) + + try: + ocm = OrderChangeManager( + order=order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=request.auth, + notify=serializer.validated_data.get('send_email', False), + reissue_invoice=serializer.validated_data.get('reissue_invoice', True), + ) + + canceled_positions = set() + for r in serializer.validated_data.get('cancel_positions', []): + ocm.cancel(r['position']) + canceled_positions.add(r['position']) + + for r in serializer.validated_data.get('patch_positions', []): + if r['position'] in canceled_positions: + continue + pos_serializer = OrderPositionChangeSerializer( + context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()}, + partial=True, + ) + pos_serializer.update(r['position'], r['body']) + + for r in serializer.validated_data.get('split_positions', []): + if r['position'] in canceled_positions: + continue + ocm.split(r['position']) + + for r in serializer.validated_data.get('create_positions', []): + pos_serializer = OrderPositionCreateForExistingOrderSerializer( + context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()}, + ) + pos_serializer.create(r) + + canceled_fees = set() + for r in serializer.validated_data.get('cancel_fees', []): + ocm.cancel_fee(r['fee']) + canceled_fees.add(r['fee']) + + for r in serializer.validated_data.get('patch_fees', []): + if r['fee'] in canceled_fees: + continue + pos_serializer = OrderFeeChangeSerializer( + context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()}, + ) + pos_serializer.update(r['fee'], r['body']) + + if serializer.validated_data.get('recalculate_taxes') == 'keep_net': + ocm.recalculate_taxes(keep='net') + elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross': + ocm.recalculate_taxes(keep='gross') + + ocm.commit() + except OrderError as e: + raise ValidationError(str(e)) + + order.refresh_from_db() + serializer = OrderSerializer( + instance=order, + context=self.get_serializer_context(), + ) + return Response(serializer.data) + with scopes_disabled(): class OrderPositionFilter(FilterSet): @@ -823,7 +902,7 @@ with scopes_disabled(): } -class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet): +class OrderPositionViewSet(viewsets.ModelViewSet): serializer_class = OrderPositionSerializer queryset = OrderPosition.all.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -1060,6 +1139,25 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi ) return resp + @action(detail=True, methods=['POST']) + def regenerate_secrets(self, request, **kwargs): + instance = self.get_object() + try: + ocm = OrderChangeManager( + instance.order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth, + notify=False, + reissue_invoice=False, + ) + ocm.regenerate_secret(instance) + ocm.commit() + except OrderError as e: + raise ValidationError(str(e)) + except Quota.QuotaExceededException as e: + raise ValidationError(str(e)) + return self.retrieve(request, [], **kwargs) + def perform_destroy(self, instance): try: ocm = OrderChangeManager( @@ -1075,18 +1173,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi except Quota.QuotaExceededException as e: raise ValidationError(str(e)) - def update(self, request, *args, **kwargs): - partial = kwargs.get('partial', False) - if not partial: - return Response( - {"detail": "Method \"PUT\" not allowed."}, - status=status.HTTP_405_METHOD_NOT_ALLOWED, - ) - return super().update(request, *args, **kwargs) - - def perform_update(self, serializer): + def create(self, request, *args, **kwargs): with transaction.atomic(): - old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data + serializer = OrderPositionCreateForExistingOrderSerializer( + data=request.data, + context=self.get_serializer_context(), + ) + serializer.is_valid(raise_exception=True) + order = serializer.validated_data['order'] + ocm = OrderChangeManager( + order=order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=request.auth, + notify=False, + reissue_invoice=False, + ) + serializer.context['ocm'] = ocm + serializer.save() + + # Fields that can be easily patched after the position was added + old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data + serializer = OrderPositionInfoPatchSerializer( + instance=serializer.instance, + context=self.get_serializer_context(), + partial=True, + data=request.data + ) + serializer.is_valid(raise_exception=True) serializer.save() new_data = serializer.data @@ -1109,9 +1222,77 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi ] } ) + tickets.invalidate_cache.apply_async( + kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk}) + order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order) + return Response( + OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data, + status=status.HTTP_201_CREATED, + ) - tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk}) - order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order) + def update(self, request, *args, **kwargs): + partial = kwargs.get('partial', False) + if not partial: + return Response( + {"detail": "Method \"PUT\" not allowed."}, + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + + with transaction.atomic(): + instance = self.get_object() + ocm = OrderChangeManager( + order=instance.order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=request.auth, + notify=False, + reissue_invoice=False, + ) + + # Field that need to go through OrderChangeManager + serializer = OrderPositionChangeSerializer( + instance=instance, + context={'ocm': ocm, **self.get_serializer_context()}, + partial=True, + data=request.data + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + # Fields that can be easily patched + old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data + serializer = OrderPositionInfoPatchSerializer( + instance=instance, + context=self.get_serializer_context(), + partial=True, + data=request.data + ) + serializer.is_valid(raise_exception=True) + serializer.save() + new_data = serializer.data + + if old_data != new_data: + log_data = self.request.data + if 'answers' in log_data: + for a in new_data['answers']: + log_data[f'question_{a["question"]}'] = a["answer"] + log_data.pop('answers', None) + serializer.instance.order.log_action( + 'pretix.event.order.modified', + user=self.request.user, + auth=self.request.auth, + data={ + 'data': [ + dict( + position=serializer.instance.pk, + **log_data + ) + ] + } + ) + tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk}) + order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order) + + return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data) class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index b5b24bb75d..38357c029c 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1467,7 +1467,7 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): self._invoice_dirty = True - def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, + def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None): if isinstance(seat, str): if not seat: @@ -1492,6 +1492,8 @@ class OrderChangeManager: if price is None: raise OrderError(self.error_messages['product_invalid']) + if item.variations.exists() and not variation: + raise OrderError(self.error_messages['product_without_variation']) if not addon_to and item.category and item.category.is_addon: raise OrderError(self.error_messages['addon_to_required']) if addon_to: diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py new file mode 100644 index 0000000000..cefe963161 --- /dev/null +++ b/src/tests/api/test_invoices.py @@ -0,0 +1,311 @@ +# +# 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 . +# +# 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 +# . +# +import datetime +from decimal import Decimal +from unittest import mock + +import freezegun +import pytest +from django_countries.fields import Country +from django_scopes import scopes_disabled +from pytz import UTC + +from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.models.orders import OrderFee +from pretix.base.services.invoices import ( + generate_cancellation, generate_invoice, +) + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def item2(event2): + return event2.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00')) + + +@pytest.fixture +def question(event, item): + q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item) + q.options.create(answer="XL", identifier="LVETRWVU") + return q + + +@pytest.fixture +def question2(event2, item2): + q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item2) + return q + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +@pytest.fixture +def order(event, item, taxrule, question): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + event.plugins += ",pretix.plugins.stripe" + event.save() + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + 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=UTC), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, locale='en' + ) + p1 = o.payments.create( + provider='stripe', + state='refunded', + amount=Decimal('23.00'), + payment_date=testtime, + ) + o.refunds.create( + provider='stripe', + state='done', + source='admin', + amount=Decimal('23.00'), + execution_date=testtime, + payment=p1, + ) + o.payments.create( + provider='banktransfer', + state='pending', + amount=Decimal('23.00'), + ) + 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) + 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, canceled=True) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'), + vat_id="DE123", vat_id_validated=True) + op = 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, + ) + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="YBiYJrmF5ufiTLdV1iDf", + pseudonymization_id="JKLM", + canceled=True, + positionid=2, + ) + op.answers.create(question=question, answer='S') + return o + + +@pytest.fixture +def invoice(order): + testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + return generate_invoice(order) + + +TEST_INVOICE_RES = { + "order": "FOO", + "number": "DUMMY-00001", + "is_cancellation": False, + "invoice_from_name": "", + "invoice_from": "", + "invoice_from_zipcode": "", + "invoice_from_city": "", + "invoice_from_country": None, + "invoice_from_tax_id": "", + "invoice_from_vat_id": "", + "invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123", + "invoice_to_company": "Sample company", + "invoice_to_name": "", + "invoice_to_street": "", + "invoice_to_zipcode": "", + "invoice_to_city": "", + "invoice_to_state": "", + "invoice_to_country": "NZ", + "invoice_to_vat_id": "DE123", + "invoice_to_beneficiary": "", + "custom_field": None, + "date": "2017-12-10", + "refers": None, + "locale": "en", + "introductory_text": "", + "internal_reference": "", + "additional_text": "", + "payment_provider_text": "", + "footer_text": "", + "foreign_currency_display": None, + "foreign_currency_rate": None, + "foreign_currency_rate_date": None, + "lines": [ + { + "position": 1, + "description": "Budget Ticket
Attendee: Peter", + 'subevent': None, + 'event_date_from': '2017-12-27T10:00:00Z', + 'event_date_to': None, + 'event_location': None, + 'attendee_name': 'Peter', + 'item': None, + 'variation': None, + 'fee_type': None, + 'fee_internal_type': None, + "gross_value": "23.00", + "tax_value": "0.00", + "tax_name": "", + "tax_rate": "0.00" + }, + { + "position": 2, + "description": "Payment fee", + 'subevent': None, + 'event_date_from': '2017-12-27T10:00:00Z', + 'event_date_to': None, + 'event_location': None, + 'attendee_name': None, + 'fee_type': "payment", + 'fee_internal_type': None, + 'item': None, + 'variation': None, + "gross_value": "0.25", + "tax_value": "0.05", + "tax_name": "", + "tax_rate": "19.00" + } + ] +} + + +@pytest.mark.django_db +def test_invoice_list(token_client, organizer, event, order, item, invoice): + res = dict(TEST_INVOICE_RES) + res['lines'][0]['item'] = item.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=BAR'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number={}'.format( + organizer.slug, event.slug, invoice.number)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number=XXX'.format( + organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=en'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=de'.format( + organizer.slug, event.slug)) + assert [] == resp.data['results'] + + with scopes_disabled(): + ic = generate_cancellation(invoice) + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=false'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=true'.format( + organizer.slug, event.slug)) + assert len(resp.data['results']) == 1 + assert resp.data['results'][0]['number'] == ic.number + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( + organizer.slug, event.slug, invoice.number)) + assert len(resp.data['results']) == 1 + assert resp.data['results'][0]['number'] == ic.number + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( + organizer.slug, event.slug, ic.number)) + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_invoice_detail(token_client, organizer, event, item, invoice): + res = dict(TEST_INVOICE_RES) + res['lines'][0]['item'] = item.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/{}/'.format(organizer.slug, event.slug, + invoice.number)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_invoice_regenerate(token_client, organizer, event, invoice): + organizer.settings.invoice_regenerate_allowed = True + with scopes_disabled(): + InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") + + with freezegun.freeze_time("2017-12-10"): + resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/regenerate/'.format( + organizer.slug, event.slug, invoice.number + )) + assert resp.status_code == 204 + invoice.refresh_from_db() + assert "ACME Ltd" in invoice.invoice_to + + +@pytest.mark.django_db +def test_invoice_reissue(token_client, organizer, event, invoice): + with scopes_disabled(): + InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") + + resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/reissue/'.format( + organizer.slug, event.slug, invoice.number + )) + assert resp.status_code == 204 + invoice.refresh_from_db() + assert "ACME Ltd" not in invoice.invoice_to + with scopes_disabled(): + assert invoice.order.invoices.count() == 3 + invoice = invoice.order.invoices.last() + assert "ACME Ltd" in invoice.invoice_to diff --git a/src/tests/api/test_order_change.py b/src/tests/api/test_order_change.py new file mode 100644 index 0000000000..d15f36a400 --- /dev/null +++ b/src/tests/api/test_order_change.py @@ -0,0 +1,1917 @@ +# +# 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 . +# +# 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 +# . +# +import datetime +import json +from decimal import Decimal +from unittest import mock + +import pytest +from django.core import mail as djmail +from django.core.files.base import ContentFile +from django.utils.timezone import now +from django_countries.fields import Country +from django_scopes import scopes_disabled +from pytz import UTC + +from pretix.base.models import ( + InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, +) +from pretix.base.models.orders import OrderFee +from pretix.base.services.invoices import generate_invoice + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def item2(event2): + return event2.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00')) + + +@pytest.fixture +def question(event, item): + q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item) + q.options.create(answer="XL", identifier="LVETRWVU") + return q + + +@pytest.fixture +def question2(event2, item2): + q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item2) + return q + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +@pytest.fixture +def seat(event, organizer, item): + SeatingPlan.objects.create( + name="Plan", organizer=organizer, layout="{}" + ) + event.seat_category_mappings.create( + layout_category='Stalls', product=item + ) + return event.seats.create(seat_number="A1", product=item, seat_guid="A1") + + +@pytest.fixture +def order(event, item, taxrule, question): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + event.plugins += ",pretix.plugins.stripe" + event.save() + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + 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=UTC), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, locale='en' + ) + p1 = o.payments.create( + provider='stripe', + state='refunded', + amount=Decimal('23.00'), + payment_date=testtime, + ) + o.refunds.create( + provider='stripe', + state='done', + source='admin', + amount=Decimal('23.00'), + execution_date=testtime, + payment=p1, + ) + o.payments.create( + provider='banktransfer', + state='pending', + amount=Decimal('23.00'), + ) + 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) + 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, canceled=True) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'), + vat_id="DE123", vat_id_validated=True) + op = 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, + ) + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="YBiYJrmF5ufiTLdV1iDf", + pseudonymization_id="JKLM", + canceled=True, + positionid=2, + ) + op.answers.create(question=question, answer='S') + return o + + +@pytest.mark.django_db +def test_order_update_ignore_fields(token_client, organizer, event, order): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'status': 'c' + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == 'n' + + +@pytest.mark.django_db +def test_order_update_only_partial(token_client, organizer, event, order): + resp = token_client.put( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'status': 'c' + } + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_order_update_state_validation(token_client, organizer, event, order): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "John Doe", + "name_parts": {}, + "street": "", + "state": "", + "zipcode": "", + "city": "Paris", + "country": "NONEXISTANT", + "internal_reference": "", + "vat_id": "", + } + } + ) + assert resp.status_code == 400 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "John Doe", + "name_parts": {}, + "street": "", + "state": "NONEXISTANT", + "zipcode": "", + "city": "Test", + "country": "AU", + "internal_reference": "", + "vat_id": "", + } + } + ) + assert resp.status_code == 400 + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "John Doe", + "name_parts": {}, + "street": "", + "state": "QLD", + "zipcode": "", + "city": "Test", + "country": "AU", + "internal_reference": "", + "vat_id": "", + } + } + ) + assert resp.status_code == 200 + order.invoice_address.refresh_from_db() + assert order.invoice_address.state == "QLD" + assert order.invoice_address.country == "AU" + + +@pytest.mark.django_db +def test_order_update_allowed_fields(token_client, organizer, event, order): + event.settings.locales = ['de', 'en'] + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'comment': 'Here is a comment', + 'custom_followup_at': '2021-06-12', + 'checkin_attention': True, + 'email': 'foo@bar.com', + 'phone': '+4962219999', + 'locale': 'de', + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "John Doe", + "name_parts": {}, + "street": "", + "state": "", + "zipcode": "", + "city": "Paris", + "country": "FR", + "internal_reference": "", + "vat_id": "", + } + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.comment == 'Here is a comment' + assert order.custom_followup_at.isoformat() == '2021-06-12' + assert order.checkin_attention + assert order.email == 'foo@bar.com' + assert order.phone == '+4962219999' + assert order.locale == 'de' + assert order.invoice_address.company == "This is my company name" + assert order.invoice_address.name_cached == "John Doe" + assert order.invoice_address.name_parts == {'_legacy': 'John Doe'} + assert str(order.invoice_address.country) == "FR" + assert not order.invoice_address.vat_id_validated + assert order.invoice_address.city == "Paris" + with scopes_disabled(): + assert order.all_logentries().get(action_type='pretix.event.order.comment') + assert order.all_logentries().get(action_type='pretix.event.order.custom_followup_at') + assert order.all_logentries().get(action_type='pretix.event.order.checkin_attention') + assert order.all_logentries().get(action_type='pretix.event.order.contact.changed') + assert order.all_logentries().get(action_type='pretix.event.order.phone.changed') + assert order.all_logentries().get(action_type='pretix.event.order.locale.changed') + assert order.all_logentries().get(action_type='pretix.event.order.modified') + + +@pytest.mark.django_db +def test_order_update_validated_vat_id(token_client, organizer, event, order): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "John Doe", + "name_parts": {}, + "street": "", + "state": "", + "zipcode": "", + "city": "Paris", + "country": "FR", + "internal_reference": "", + "vat_id": "FR123", + "vat_id_validated": True + } + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.invoice_address.vat_id == "FR123" + assert order.invoice_address.vat_id_validated + + +@pytest.mark.django_db +def test_order_update_invoiceaddress_delete_create(token_client, organizer, event, order): + event.settings.locales = ['de', 'en'] + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': None, + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + with pytest.raises(InvoiceAddress.DoesNotExist): + order.invoice_address + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'invoice_address': { + "is_business": False, + "company": "This is my company name", + "name": "", + "name_parts": {}, + "street": "", + "state": "", + "zipcode": "", + "city": "Paris", + "country": "Fr", + "internal_reference": "", + "vat_id": "", + } + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.invoice_address.company == "This is my company name" + assert str(order.invoice_address.country) == "FR" + assert order.invoice_address.city == "Paris" + + +@pytest.mark.django_db +def test_order_update_email_to_none(token_client, organizer, event, order): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'email': None, + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.email is None + + +@pytest.mark.django_db +def test_order_update_locale_to_invalid(token_client, organizer, event, order): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orders/{}/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'locale': 'de', + } + ) + assert resp.status_code == 400 + assert resp.data == {'locale': ['"de" is not a supported locale for this event.']} + + +@pytest.mark.django_db +def test_order_create_invoice(token_client, organizer, event, order): + event.settings.invoice_generate = 'True' + + event.settings.invoice_generate_sales_channels = [] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 400 + + event.settings.invoice_generate_sales_channels = ['web'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 201 + with scopes_disabled(): + pos = order.positions.first() + assert json.loads(json.dumps(resp.data)) == { + 'order': 'FOO', + 'number': 'DUMMY-00001', + 'is_cancellation': False, + "invoice_from_name": "", + "invoice_from": "", + "invoice_from_zipcode": "", + "invoice_from_city": "", + "invoice_from_country": None, + "invoice_from_tax_id": "", + "invoice_from_vat_id": "", + "invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123", + "invoice_to_company": "Sample company", + "invoice_to_name": "", + "invoice_to_street": "", + "invoice_to_zipcode": "", + "invoice_to_city": "", + "invoice_to_state": "", + "invoice_to_country": "NZ", + "invoice_to_vat_id": "DE123", + "invoice_to_beneficiary": "", + "custom_field": None, + 'date': now().date().isoformat(), + 'refers': None, + 'locale': 'en', + 'introductory_text': '', + 'additional_text': '', + 'payment_provider_text': '', + 'footer_text': '', + 'lines': [ + { + 'position': 1, + 'description': 'Budget Ticket
Attendee: Peter', + 'subevent': None, + 'event_date_from': '2017-12-27T10:00:00Z', + 'event_date_to': None, + 'event_location': None, + 'fee_type': None, + 'fee_internal_type': None, + 'attendee_name': 'Peter', + 'item': pos.item_id, + 'variation': None, + 'gross_value': '23.00', + 'tax_value': '0.00', + 'tax_rate': '0.00', + 'tax_name': '' + }, + { + 'position': 2, + 'description': 'Payment fee', + 'subevent': None, + 'event_date_from': '2017-12-27T10:00:00Z', + 'event_date_to': None, + 'event_location': None, + 'fee_type': "payment", + 'fee_internal_type': None, + 'attendee_name': None, + 'item': None, + 'variation': None, + 'gross_value': '0.25', + 'tax_value': '0.05', + 'tax_rate': '19.00', + 'tax_name': '' + } + ], + 'foreign_currency_display': None, + 'foreign_currency_rate': None, + 'foreign_currency_rate_date': None, + 'internal_reference': '' + } + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.data == {'detail': 'An invoice for this order already exists.'} + assert resp.status_code == 400 + + event.settings.invoice_generate = 'False' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 400 + assert resp.data == {'detail': 'You cannot generate an invoice for this order.'} + + +@pytest.mark.django_db +def test_order_regenerate_secrets(token_client, organizer, event, order): + s = order.secret + with scopes_disabled(): + ps = order.positions.first().secret + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/regenerate_secrets/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert s != order.secret + with scopes_disabled(): + assert ps != order.positions.first().secret + + +@pytest.mark.django_db +def test_position_regenerate_secrets(token_client, organizer, event, order): + with scopes_disabled(): + p = order.positions.first() + ps = p.secret + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/regenerate_secrets/'.format( + organizer.slug, event.slug, p.pk, + ), format='json', data={} + ) + assert resp.status_code == 200 + p.refresh_from_db() + with scopes_disabled(): + assert ps != p.secret + + +@pytest.mark.django_db +def test_order_resend_link(token_client, organizer, event, order): + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/resend_link/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 204 + assert len(djmail.outbox) == 1 + + order.email = None + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/resend_link/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={} + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_orderposition_price_calculation(token_client, organizer, event, order, item): + with scopes_disabled(): + op = order.positions.first() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('23.00'), + 'gross_formatted': '23.00', + 'name': '', + 'net': Decimal('23.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_item_with_tax(token_client, organizer, event, order, item, taxrule): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23, tax_rule=taxrule) + op = order.positions.first() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('23.00'), + 'gross_formatted': '23.00', + 'name': '', + 'net': Decimal('19.33'), + 'rate': Decimal('19.00'), + 'tax_rule': taxrule.pk, + 'tax': Decimal('3.67') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_item_with_variation(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + var = item2.variations.create(default_price=12, value="XS") + op = order.positions.first() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + 'variation': var.pk + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('12.00'), + 'gross_formatted': '12.00', + 'name': '', + 'net': Decimal('12.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_subevent(token_client, organizer, event, order, subevent): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + op = order.positions.first() + op.subevent = subevent + op.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + 'subevent': subevent.pk + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('23.00'), + 'gross_formatted': '23.00', + 'name': '', + 'net': Decimal('23.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_subevent_with_override(token_client, organizer, event, order, subevent): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + se2.subeventitem_set.create(item=item2, price=12) + op = order.positions.first() + op.subevent = subevent + op.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + 'subevent': se2.pk + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('12.00'), + 'gross_formatted': '12.00', + 'name': '', + 'net': Decimal('12.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_voucher_matching(token_client, organizer, event, order, subevent, item): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="Quota") + q.items.add(item) + q.items.add(item2) + voucher = event.vouchers.create(price_mode="set", value=15, quota=q) + op = order.positions.first() + op.voucher = voucher + op.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('15.00'), + 'gross_formatted': '15.00', + 'name': '', + 'net': Decimal('15.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_voucher_not_matching(token_client, organizer, event, order, subevent, item): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="Quota") + q.items.add(item) + voucher = event.vouchers.create(price_mode="set", value=15, quota=q) + op = order.positions.first() + op.voucher = voucher + op.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('23.00'), + 'gross_formatted': '23.00', + 'name': '', + 'net': Decimal('23.00'), + 'rate': Decimal('0.00'), + 'tax_rule': None, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_net_price(token_client, organizer, event, order, subevent, item, taxrule): + taxrule.price_includes_tax = False + taxrule.save() + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule) + op = order.positions.first() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('11.90'), + 'gross_formatted': '11.90', + 'name': '', + 'net': Decimal('10.00'), + 'rate': Decimal('19.00'), + 'tax_rule': taxrule.pk, + 'tax': Decimal('1.90') + } + + +@pytest.mark.django_db +def test_orderposition_price_calculation_reverse_charge(token_client, organizer, event, order, subevent, item, taxrule): + taxrule.price_includes_tax = False + taxrule.eu_reverse_charge = True + taxrule.home_country = Country('DE') + taxrule.save() + order.invoice_address.is_business = True + order.invoice_address.vat_id = 'ATU1234567' + order.invoice_address.vat_id_validated = True + order.invoice_address.country = Country('AT') + order.invoice_address.save() + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule) + op = order.positions.first() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), + data={ + 'item': item2.pk, + } + ) + assert resp.status_code == 200 + assert resp.data == { + 'gross': Decimal('10.00'), + 'gross_formatted': '10.00', + 'name': '', + 'net': Decimal('10.00'), + 'rate': Decimal('0.00'), + 'tax_rule': taxrule.pk, + 'tax': Decimal('0.00') + } + + +@pytest.mark.django_db +def test_position_update_ignore_fields(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data={ + 'tax_rate': '99.99' + } + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.tax_rate == Decimal('0.00') + + +@pytest.mark.django_db +def test_position_update_only_partial(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + resp = token_client.put( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data={ + 'price': '99.99' + } + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_position_update_info(token_client, organizer, event, order, question): + with scopes_disabled(): + op = order.positions.first() + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + opt = question.options.create(answer="L") + payload = { + 'company': 'VILE', + 'attendee_name_parts': { + 'full_name': 'Max Mustermann' + }, + 'street': 'Sesame Street 21', + 'zipcode': '99999', + 'city': 'Springfield', + 'country': 'US', + 'state': 'CA', + 'attendee_email': 'foo@example.org', + 'answers': [ + { + 'question': question.pk, + 'answer': 'ignored', + 'options': [opt.pk] + } + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + assert resp.data['answers'] == [ + { + 'question': question.pk, + 'question_identifier': question.identifier, + 'answer': 'L', + 'options': [opt.pk], + 'option_identifiers': [opt.identifier], + } + ] + op.refresh_from_db() + assert op.company == 'VILE' + assert op.attendee_name_cached == 'Max Mustermann' + assert op.attendee_name_parts == { + '_scheme': 'full', + 'full_name': 'Max Mustermann' + } + with scopes_disabled(): + assert op.answers.get().answer == 'L' + assert op.street == 'Sesame Street 21' + assert op.zipcode == '99999' + assert op.city == 'Springfield' + assert str(op.country) == 'US' + assert op.state == 'CA' + assert op.attendee_email == 'foo@example.org' + le = order.all_logentries().last() + assert le.action_type == 'pretix.event.order.modified' + assert le.parsed_data == { + 'data': [ + { + 'position': op.pk, + 'company': 'VILE', + 'attendee_name_parts': { + '_scheme': 'full', + 'full_name': 'Max Mustermann' + }, + 'street': 'Sesame Street 21', + 'zipcode': '99999', + 'city': 'Springfield', + 'country': 'US', + 'state': 'CA', + 'attendee_email': 'foo@example.org', + f'question_{question.pk}': 'L' + } + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert order.all_logentries().last().pk == le.pk + + +@pytest.mark.django_db +def test_position_update_legacy_name(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'attendee_name': 'Max Mustermann', + 'attendee_name_parts': { + '_legacy': 'maria' + }, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + payload = { + 'attendee_name': 'Max Mustermann', + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.attendee_name_cached == 'Max Mustermann' + assert op.attendee_name_parts == { + '_legacy': 'Max Mustermann' + } + with scopes_disabled(): + assert op.answers.count() == 1 # answer does not get deleted + + +@pytest.mark.django_db +def test_position_update_state_validation(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'country': 'DE', + 'state': 'BW' + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_position_update_question_handling(token_client, organizer, event, order, question): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'answers': [ + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + payload = { + 'answers': [ + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert op.answers.count() == 1 + payload = { + 'answers': [ + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert op.answers.count() == 0 + + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + + payload = { + 'answers': [ + { + "question": question.id, + "answer": file_id_png + } + ] + } + question.type = Question.TYPE_FILE + question.save() + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + answ = op.answers.get() + assert answ.file + assert answ.answer.startswith("file://") + + payload = { + 'answers': [ + { + "question": question.id, + "answer": "file:keep" + } + ] + } + question.type = Question.TYPE_FILE + question.save() + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + answ = op.answers.get() + assert answ.file + assert answ.answer.startswith("file://") + + +@pytest.mark.django_db +def test_position_update_change_item(token_client, organizer, event, order, quota): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + quota.items.add(item2) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.item == item2 + + +@pytest.mark.django_db +def test_position_update_change_item_wrong_event(token_client, organizer, event, event2, order, quota): + with scopes_disabled(): + item2 = event2.items.create(name="Budget Ticket", default_price=23) + quota.items.add(item2) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'object does not exist.' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_no_quota(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_variation(token_client, organizer, event, order, quota): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + v = item2.variations.create(value="foo") + quota.items.add(item2) + quota.variations.add(v) + op = order.positions.first() + payload = { + 'item': item2.pk, + 'variation': v.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.item == item2 + assert op.variation == v + + +@pytest.mark.django_db +def test_position_update_change_item_variation_required(token_client, organizer, event, order, quota): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + v = item2.variations.create(value="foo") + quota.items.add(item2) + quota.variations.add(v) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'variation' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_variation_mismatch(token_client, organizer, event, order, quota): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + v = item2.variations.create(value="foo") + item3 = event.items.create(name="Budget Ticket", default_price=23) + v3 = item3.variations.create(value="foo") + quota.items.add(item2) + quota.items.add(item3) + quota.variations.add(v) + quota.variations.add(v3) + op = order.positions.first() + payload = { + 'item': item2.pk, + 'variation': v3.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'variation' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_subevent(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + q2 = se2.quotas.create(name="foo", size=1, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.subevent == se2 + + +@pytest.mark.django_db +def test_position_update_change_subevent_quota_empty(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + q2 = se2.quotas.create(name="foo", size=0, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_seat(token_client, organizer, event, order, quota, item, seat): + with scopes_disabled(): + seat2 = event.seats.create(seat_number="A2", product=item, seat_guid="A2") + op = order.positions.first() + op.seat = seat + op.save() + payload = { + 'seat': seat2.seat_guid, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.seat == seat2 + + +@pytest.mark.django_db +def test_position_update_unset_seat(token_client, organizer, event, order, quota, item, seat): + with scopes_disabled(): + op = order.positions.first() + op.seat = seat + op.save() + payload = { + 'seat': None, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.seat is None + + +@pytest.mark.django_db +def test_position_update_change_seat_taken(token_client, organizer, event, order, quota, item, seat): + with scopes_disabled(): + seat2 = event.seats.create(seat_number="A2", product=item, seat_guid="A2", blocked=True) + op = order.positions.first() + op.seat = seat + op.save() + payload = { + 'seat': seat2.seat_guid, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'seat' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_subevent_keep_seat(token_client, organizer, event, order, quota, item, subevent, seat): + with scopes_disabled(): + seat.subevent = subevent + seat.save() + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + seat2 = event.seats.create(seat_number="A1", product=item, seat_guid="A1", subevent=se2) + q2 = se2.quotas.create(name="foo", size=1, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.seat = seat + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.subevent == se2 + assert op.seat == seat2 + + +@pytest.mark.django_db +def test_position_update_change_subevent_missing_seat(token_client, organizer, event, order, quota, item, subevent, seat): + with scopes_disabled(): + seat.subevent = subevent + seat.save() + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + q2 = se2.quotas.create(name="foo", size=1, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.seat = seat + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'seat' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_price(token_client, organizer, event, order, quota): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'price': Decimal('119.00') + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.price == Decimal('119.00') + assert op.tax_rate == Decimal('0.00') + assert op.tax_value == Decimal('0.00') + + +@pytest.mark.django_db +def test_position_update_change_price_and_tax_rule(token_client, organizer, event, order, quota): + with scopes_disabled(): + op = order.positions.first() + tr = event.tax_rules.create(rate=19) + payload = { + 'price': Decimal('119.00'), + 'tax_rule': tr.pk + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.price == Decimal('119.00') + assert op.tax_rate == Decimal('19.00') + assert op.tax_value == Decimal('19.00') + assert op.tax_rule == tr + + +@pytest.mark.django_db +def test_position_add_simple(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + + +@pytest.mark.django_db +def test_position_add_price(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + 'price': '99.99' + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == Decimal('99.99') + assert op.positionid == 3 + + +@pytest.mark.django_db +def test_position_add_subevent(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + assert order.positions.count() == 1 + quota.subevent = subevent + quota.save() + payload = { + 'order': order.code, + 'item': item.pk, + 'subevent': subevent.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + assert op.subevent == subevent + + +@pytest.mark.django_db +def test_position_add_subevent_required(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'subevent' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_quota_empty(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + quota.size = 1 + quota.save() + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_seat(token_client, organizer, event, order, quota, item, seat): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + 'seat': seat.seat_guid, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + assert op.seat == seat + + +@pytest.mark.django_db +def test_position_add_seat_required(token_client, organizer, event, order, quota, item, seat): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'seat' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_addon_to(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + cat = event.categories.create(name="Workshops") + item2 = event.items.create(name="WS1", default_price=23, category=cat) + quota.items.add(item2) + item.addons.create(addon_category=cat) + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item2.pk, + 'addon_to': 1, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.positionid == 3 + assert op.addon_to.positionid == 1 + + +@pytest.mark.django_db +def test_position_add_addon_to_canceled_position(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + cat = event.categories.create(name="Workshops") + item2 = event.items.create(name="WS1", default_price=23, category=cat) + quota.items.add(item2) + item.addons.create(addon_category=cat) + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item2.pk, + 'addon_to': 2, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'unknown position' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_addon_to_wrong_product(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + 'addon_to': 1, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'selected base position does not allow you to add this product as an add-on' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_and_set_info(token_client, organizer, event, order, question, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item.pk, + 'attendee_name': 'John Doe', + 'answers': [ + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + assert op.attendee_name == 'John Doe' + assert op.answers.count() == 1 + + +@pytest.mark.django_db +def test_order_change_patch(token_client, organizer, event, order, quota): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + quota.items.add(item2) + p = order.positions.first() + f = order.fees.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ], + 'patch_fees': [ + { + 'fee': f.pk, + 'body': { + 'value': '10.00', + } + } + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + p.refresh_from_db() + assert p.price == Decimal('99.44') + assert p.item == item2 + f.refresh_from_db() + assert f.value == Decimal('10.00') + + +@pytest.mark.django_db +def test_order_change_cancel_and_create(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + p = order.positions.first() + f = order.fees.first() + quota.size = 0 + quota.save() + payload = { + 'cancel_positions': [ + { + 'position': p.pk, + }, + ], + 'create_positions': [ + { + 'item': item.pk, + 'price': '99.99' + }, + ], + 'cancel_fees': [ + { + 'fee': f.pk, + } + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + p.refresh_from_db() + assert p.canceled + p_new = order.positions.last() + assert p_new != p + assert p_new.item == item + assert p_new.price == Decimal('99.99') + f.refresh_from_db() + assert f.canceled + + +@pytest.mark.django_db +def test_order_change_send_email_reissue_invoice(token_client, organizer, event, order, quota, item): + djmail.outbox = [] + with scopes_disabled(): + f = order.fees.first() + generate_invoice(order) + payload = { + 'send_email': False, + 'reissue_invoice': True, + 'create_positions': [ + { + 'item': item.pk, + 'price': '99.99' + }, + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + assert len(djmail.outbox) == 0 + with scopes_disabled(): + assert order.invoices.count() == 3 + payload = { + 'send_email': True, + 'reissue_invoice': False, + 'cancel_fees': [ + { + 'fee': f.pk, + } + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + assert len(djmail.outbox) == 1 + with scopes_disabled(): + assert order.invoices.count() == 3 + + +@pytest.mark.django_db +def test_order_change_recalculate_taxes(token_client, organizer, event, order, quota, item): + djmail.outbox = [] + with scopes_disabled(): + tax_rule = event.tax_rules.create(rate=7) + p = order.positions.first() + p.tax_rule = tax_rule + p.save() + assert p.tax_rate == 0 + payload = { + 'recalculate_taxes': 'keep_gross', + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + + with scopes_disabled(): + p.refresh_from_db() + assert p.tax_rule == tax_rule + assert p.tax_rate == Decimal('7.00') + assert p.price == Decimal('23.00') + assert p.tax_value == Decimal('1.50') + + tax_rule.rate = 10 + tax_rule.save() + payload = { + 'recalculate_taxes': 'keep_net', + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + + with scopes_disabled(): + p.refresh_from_db() + assert p.tax_rule == tax_rule + assert p.tax_rate == Decimal('10.00') + assert p.price == Decimal('23.65') + assert p.tax_value == Decimal('2.15') + + +@pytest.mark.django_db +def test_order_change_split(token_client, organizer, event, order): + djmail.outbox = [] + with scopes_disabled(): + p_canceled = order.all_positions.filter(canceled=True).first() + p_canceled.canceled = False + p_canceled.save() + assert event.orders.count() == 1 + payload = { + 'split_positions': [ + {'position': p_canceled.pk} + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert event.orders.count() == 2 + + +@pytest.mark.django_db +def test_order_change_invalid_input(token_client, organizer, event, order, quota, item, item2): + djmail.outbox = [] + with scopes_disabled(): + tax_rule = event.tax_rules.create(rate=7) + p = order.positions.first() + p_canceled = order.all_positions.filter(canceled=True).first() + f_canceled = order.all_fees.filter(canceled=True).first() + p.tax_rule = tax_rule + p.save() + assert p.tax_rate == 0 + payload = { + 'cancel_fees': [ + {'fee': f_canceled.pk} + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'does not exist' in str(resp.data) + assert resp.status_code == 400 + payload = { + 'patch_positions': [ + {'position': p_canceled.pk, 'body': {'price': '99.00'}} + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'does not exist' in str(resp.data) + assert resp.status_code == 400 + payload = { + 'patch_positions': [ + {'position': p.pk, 'body': {'item': item2.pk}} + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'does not exist' in str(resp.data) + assert resp.status_code == 400 + payload = { + 'cancel_positions': [ + {'position': p.pk} + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'empty' in str(resp.data) + assert resp.status_code == 400 + payload = { + 'split_positions': [ + {'position': p.pk} + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'empty' in str(resp.data) + assert resp.status_code == 400 + payload = { + 'patch_positions': [ + {'position': p.pk, 'body': {}}, + {'position': p.pk, 'body': {}}, + ], + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert 'twice' in str(resp.data) + assert resp.status_code == 400 diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py new file mode 100644 index 0000000000..420f587336 --- /dev/null +++ b/src/tests/api/test_order_create.py @@ -0,0 +1,2526 @@ +# +# 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 . +# +# 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 +# . +# +import copy +import datetime +import json +from decimal import Decimal +from unittest import mock + +import pytest +from django.core import mail as djmail +from django.core.files.base import ContentFile +from django.utils.timezone import now +from django_countries.fields import Country +from django_scopes import scopes_disabled +from pytz import UTC + +from pretix.base.models import ( + InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, +) +from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def item2(event2): + return event2.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00')) + + +@pytest.fixture +def question(event, item): + q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item) + q.options.create(answer="XL", identifier="LVETRWVU") + return q + + +@pytest.fixture +def question2(event2, item2): + q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item2) + return q + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +@pytest.fixture +def order(event, item, taxrule, question): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + event.plugins += ",pretix.plugins.stripe" + event.save() + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + 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=UTC), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, locale='en' + ) + p1 = o.payments.create( + provider='stripe', + state='refunded', + amount=Decimal('23.00'), + payment_date=testtime, + ) + o.refunds.create( + provider='stripe', + state='done', + source='admin', + amount=Decimal('23.00'), + execution_date=testtime, + payment=p1, + ) + o.payments.create( + provider='banktransfer', + state='pending', + amount=Decimal('23.00'), + ) + 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) + 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, canceled=True) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'), + vat_id="DE123", vat_id_validated=True) + op = 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, + ) + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="YBiYJrmF5ufiTLdV1iDf", + pseudonymization_id="JKLM", + canceled=True, + positionid=2, + ) + op.answers.create(question=question, answer='S') + return o + + +@pytest.fixture +def clist_autocheckin(event): + c = event.checkin_lists.create(name="Default", all_products=True, auto_checkin_sales_channels=['web']) + return c + + +ORDER_CREATE_PAYLOAD = { + "email": "dummy@dummy.test", + "phone": "+49622112345", + "locale": "en", + "sales_channel": "web", + "fees": [ + { + "fee_type": "payment", + "value": "0.25", + "description": "", + "internal_type": "", + "tax_rule": None + } + ], + "payment_provider": "banktransfer", + "invoice_address": { + "is_business": False, + "company": "Sample company", + "name_parts": {"full_name": "Fo"}, + "street": "Bar", + "state": "", + "zipcode": "", + "city": "Sample City", + "country": "NZ", + "internal_reference": "", + "vat_id": "" + }, + "positions": [ + { + "positionid": 1, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "company": "FOOCORP", + "answers": [ + { + "question": 1, + "answer": "S", + "options": [] + } + ], + "subevent": None + } + ], +} + + +@pytest.mark.django_db +def test_order_create(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.customer == customer + assert o.email == "dummy@dummy.test" + assert o.phone == "+49622112345" + assert o.locale == "en" + assert o.total == Decimal('23.25') + assert o.status == Order.STATUS_PENDING + assert o.sales_channel == "web" + assert not o.testmode + + with scopes_disabled(): + p = o.payments.first() + assert p.provider == "banktransfer" + assert p.amount == o.total + assert p.state == "created" + + with scopes_disabled(): + fee = o.fees.first() + assert fee.fee_type == "payment" + assert fee.value == Decimal('0.25') + ia = o.invoice_address + assert ia.company == "Sample company" + assert ia.name_parts == {"full_name": "Fo", "_scheme": "full"} + assert ia.name_cached == "Fo" + with scopes_disabled(): + assert o.positions.count() == 1 + pos = o.positions.first() + assert pos.item == item + assert pos.price == Decimal("23.00") + assert pos.attendee_name_parts == {"full_name": "Peter", "_scheme": "full"} + assert pos.company == "FOOCORP" + with scopes_disabled(): + answ = pos.answers.first() + assert answ.question == question + assert answ.answer == "S" + with scopes_disabled(): + assert o.transactions.count() == 2 + + +@pytest.mark.django_db +def test_order_create_simulate(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + with scopes_disabled(): + opt = question.options.create(answer="L") + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['answers'][0]['options'] = [opt.pk] + res['simulate'] = True + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert Order.objects.count() == 0 + assert QuestionAnswer.objects.count() == 0 + assert OrderPosition.objects.count() == 0 + assert OrderFee.objects.count() == 0 + assert InvoiceAddress.objects.count() == 0 + d = resp.data + del d['last_modified'] + del d['secret'] + del d['url'] + del d['expires'] + del d['invoice_address']['last_modified'] + del d['positions'][0]['secret'] + assert d == { + 'code': 'PREVIEW', + 'status': 'n', + 'testmode': False, + 'email': 'dummy@dummy.test', + 'phone': '+49622112345', + 'customer': None, + 'locale': 'en', + 'datetime': None, + 'payment_date': None, + 'payment_provider': None, + 'fees': [ + { + 'id': 0, + 'fee_type': 'payment', + 'value': '0.25', + 'description': '', + 'internal_type': '', + 'tax_rate': '0.00', + 'tax_value': '0.00', + 'tax_rule': None, + 'canceled': False + } + ], + 'total': '23.25', + 'comment': '', + "custom_followup_at": None, + 'invoice_address': { + 'is_business': False, + 'company': 'Sample company', + 'name': 'Fo', + 'name_parts': {'full_name': 'Fo', '_scheme': 'full'}, + 'street': 'Bar', + 'zipcode': '', + 'city': 'Sample City', + 'country': 'NZ', + 'state': '', + 'vat_id': '', + 'vat_id_validated': False, + 'internal_reference': '' + }, + 'positions': [ + { + 'id': 0, + 'order': '', + 'positionid': 1, + 'item': item.pk, + 'variation': None, + 'price': '23.00', + 'attendee_name': 'Peter', + 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, + 'attendee_email': None, + 'voucher': None, + 'tax_rate': '0.00', + 'tax_value': '0.00', + 'addon_to': None, + 'subevent': None, + 'checkins': [], + 'downloads': [], + 'answers': [ + {'question': question.pk, 'answer': 'L', 'question_identifier': 'ABC', + 'options': [opt.pk], + 'option_identifiers': [opt.identifier]} + ], + 'tax_rule': None, + 'pseudonymization_id': 'PREVIEW', + 'seat': None, + 'company': "FOOCORP", + 'street': None, + 'city': None, + 'zipcode': None, + 'state': None, + 'country': None, + 'canceled': False + } + ], + 'downloads': [], + 'checkin_attention': False, + 'payments': [], + 'refunds': [], + 'require_approval': False, + 'sales_channel': 'web', + } + + +@pytest.mark.django_db +def test_order_create_positionids_addons_simulated(token_client, organizer, event, item, quota): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 1, + "answers": [], + "subevent": None + } + ] + res['simulate'] = True + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + del resp.data['positions'][0]['secret'] + del resp.data['positions'][1]['secret'] + assert [dict(f) for f in resp.data['positions']] == [ + {'id': 0, 'order': '', 'positionid': 1, 'item': item.pk, 'variation': None, 'price': '23.00', + 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, + 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, + 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', + 'addon_to': None, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None, + 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False}, + {'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00', + 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, + 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, + 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', + 'addon_to': 1, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None, + 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False} + ] + + +@pytest.mark.django_db +def test_order_create_autocheckin(token_client, organizer, event, item, quota, question, clist_autocheckin): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert "web" in clist_autocheckin.auto_checkin_sales_channels + assert o.positions.first().checkins.first().auto_checked_in + + clist_autocheckin.auto_checkin_sales_channels = [] + clist_autocheckin.save() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert clist_autocheckin.auto_checkin_sales_channels == [] + assert o.positions.first().checkins.count() == 0 + + +@pytest.mark.django_db +def test_order_create_require_approval(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['require_approval'] = True + res['send_email'] = True + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.require_approval + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + assert "approval" in djmail.outbox[0].body + + +@pytest.mark.django_db +def test_order_create_invoice_address_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['invoice_address'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + with pytest.raises(InvoiceAddress.DoesNotExist): + o.invoice_address + + +@pytest.mark.django_db +def test_order_create_sales_channel_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['sales_channel'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.sales_channel == "web" + + +@pytest.mark.django_db +def test_order_create_sales_channel_invalid(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['sales_channel'] = 'foo' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'sales_channel': ['Unknown sales channel.']} + + +@pytest.mark.django_db +def test_order_create_in_test_mode(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['testmode'] = True + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.testmode + + +@pytest.mark.django_db +def test_order_create_in_test_mode_saleschannel_limited(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['testmode'] = True + res['sales_channel'] = 'baz' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'testmode': ['This sales channel does not provide support for test mode.']} + + +@pytest.mark.django_db +def test_order_create_attendee_name_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['attendee_name'] = None + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['attendee_name_parts'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.positions.first().attendee_name_parts == {} + + +@pytest.mark.django_db +def test_order_create_legacy_attendee_name(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['attendee_name'] = 'Peter' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + del res['positions'][0]['attendee_name_parts'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.positions.first().attendee_name_parts == {"_legacy": "Peter"} + + +@pytest.mark.django_db +def test_order_create_legacy_invoice_name(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['invoice_address']['name'] = 'Peter' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + del res['invoice_address']['name_parts'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.invoice_address.name_parts == {"_legacy": "Peter"} + + +@pytest.mark.django_db +def test_order_create_code_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['code'] = 'ABCDE' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.code == "ABCDE" + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'code': ['This order code is already in use.']} + + res['code'] = 'ABaDE' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'code': ['This order code contains invalid characters.']} + + +@pytest.mark.django_db +def test_order_email_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['email'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert not o.email + + +@pytest.mark.django_db +def test_order_create_payment_provider_optional_free(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['price'] = '0.00' + res['positions'][0]['status'] = 'p' + del res['payment_provider'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert not o.payments.exists() + + +@pytest.mark.django_db +def test_order_create_payment_info_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + res['payment_info'] = { + 'foo': { + 'bar': [1, 2], + 'test': False + } + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + + p = o.payments.first() + assert p.provider == "banktransfer" + assert p.amount == o.total + assert json.loads(p.info) == res['payment_info'] + + +@pytest.mark.django_db +def test_order_create_position_secret_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.positions.first().secret + + res['positions'][0]['secret'] = "aaa" + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.positions.first().secret == "aaa" + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + + assert resp.data == {'positions': [{'secret': ['You cannot assign a position secret that already exists.']}]} + + +@pytest.mark.django_db +def test_order_create_tax_rules(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['tax_rule'] = taxrule.pk + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + item.tax_rule = taxrule + item.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + fee = o.fees.first() + assert fee.fee_type == "payment" + assert fee.value == Decimal('0.25') + assert fee.tax_rate == Decimal('19.00') + assert fee.tax_rule == taxrule + ia = o.invoice_address + assert ia.company == "Sample company" + with scopes_disabled(): + pos = o.positions.first() + assert pos.item == item + assert pos.tax_rate == Decimal('19.00') + assert pos.tax_value == Decimal('3.67') + assert pos.tax_rule == taxrule + + +@pytest.mark.django_db +def test_order_create_fee_type_validation(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['fee_type'] = 'unknown' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'fees': [{'fee_type': ['"unknown" is not a valid choice.']}]} + + +@pytest.mark.django_db +def test_order_create_fee_as_percentage(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + fee = o.fees.first() + assert fee.value == Decimal('2.30') + assert o.total == Decimal('25.30') + + +@pytest.mark.django_db +def test_order_create_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + item.tax_rule = taxrule + item.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + fee = o.fees.first() + assert fee.value == Decimal('2.30') + assert fee.tax_rate == Decimal('19.00') + assert o.total == Decimal('25.30') + + +@pytest.mark.django_db +def test_order_create_negative_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['value'] = '-10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + item.tax_rule = taxrule + item.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + fee = o.fees.first() + assert fee.value == Decimal('-10.00') + assert fee.tax_value == Decimal('-1.60') + assert fee.tax_rate == Decimal('19.00') + assert o.total == Decimal('13.00') + + +@pytest.mark.django_db +def test_order_create_tax_rule_wrong_event(token_client, organizer, event, item, quota, question, taxrule2): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['tax_rule'] = taxrule2.pk + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'fees': [{'tax_rule': ['The specified tax rate does not belong to this event.']}]} + + +@pytest.mark.django_db +def test_order_create_subevent_not_allowed(token_client, organizer, event, item, quota, question, subevent2): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['subevent'] = subevent2.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'subevent': ['You cannot set a subevent for this event.']}]} + + +@pytest.mark.django_db +def test_order_create_empty(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': ['An order cannot be empty.']} + + +@pytest.mark.django_db +def test_order_create_subevent_validation(token_client, organizer, event, item, subevent, subevent2, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'subevent': ['You need to set a subevent.']}]} + + res['positions'][0]['subevent'] = subevent2.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'subevent': ['The specified subevent does not belong to this event.']}]} + + +@pytest.mark.django_db +def test_order_create_item_validation(token_client, organizer, event, item, item2, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + item.active = False + item.save() + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The specified item is not active.']}]} + item.active = True + item.save() + + res['positions'][0]['item'] = item2.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The specified item does not belong to this event.']}]} + + with scopes_disabled(): + var2 = item2.variations.create(value="A") + quota.variations.add(var2) + + res['positions'][0]['item'] = item.pk + res['positions'][0]['variation'] = var2.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'variation': ['You cannot specify a variation for this item.']}]} + + with scopes_disabled(): + var1 = item.variations.create(value="A") + res['positions'][0]['item'] = item.pk + res['positions'][0]['variation'] = var1.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not assigned to a quota.']}]} + + with scopes_disabled(): + quota.variations.add(var1) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + res['positions'][0]['variation'] = var2.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [{'variation': ['The specified variation does not belong to the specified item.']}]} + + res['positions'][0]['variation'] = None + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'variation': ['You should specify a variation for this item.']}]} + + +@pytest.mark.django_db +def test_order_create_subevent_disabled(token_client, organizer, event, item, subevent, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['subevent'] = subevent.pk + s = item.subeventitem_set.create(subevent=subevent, disabled=True) + quota.subevent = subevent + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} + + s.delete() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_subevent_variation_disabled(token_client, organizer, event, item, subevent, quota, question): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + var = item2.variations.create(default_price=12, value="XS") + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item2.pk + res['positions'][0]['variation'] = var.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['subevent'] = subevent.pk + s = var.subeventitemvariation_set.create(subevent=subevent, disabled=True) + quota.subevent = subevent + quota.items.add(item2) + quota.variations.add(var) + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} + + s.delete() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_positionids_addons(token_client, organizer, event, item, quota): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 1, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos1 = o.positions.first() + pos2 = o.positions.last() + assert pos2.addon_to == pos1 + + +@pytest.mark.django_db +def test_order_create_positionid_validation(token_client, organizer, event, item, quota): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 2, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + { + 'addon_to': [ + 'If you set addon_to, you need to make sure that the ' + 'referenced position ID exists and is transmitted directly ' + 'before its add-ons.' + ] + } + ] + } + + res['positions'] = [ + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 2, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [ + {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}, + {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} + ]} + + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + }, + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + { + 'positionid': ['If you set position IDs manually, you need to do so for all positions.'] + } + ] + } + + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + }, + { + "positionid": 3, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + { + 'positionid': ['Position IDs need to be consecutive.'] + } + ] + } + + res['positions'] = [ + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + }, + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "answers": [], + "subevent": None + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.positions.first().positionid == 1 + assert o.positions.last().positionid == 2 + + +@pytest.mark.django_db +def test_order_create_answer_validation(token_client, organizer, event, item, quota, question, question2): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question2.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [{'answers': [{'question': ['The specified question does not belong to this event.']}]}]} + + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['answers'][0]['options'] = [question.options.first().pk] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'answers': [ + {'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]}]} + + question.type = Question.TYPE_CHOICE + question.save() + res['positions'][0]['answers'][0]['options'] = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [ + {'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]} + + with scopes_disabled(): + question2.options.create(answer="L") + with scopes_disabled(): + res['positions'][0]['answers'][0]['options'] = [ + question2.options.first().pk, + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [{'answers': [{'non_field_errors': ['The specified option does not belong to this question.']}]}]} + + with scopes_disabled(): + question.options.create(answer="L") + with scopes_disabled(): + res['positions'][0]['answers'][0]['options'] = [ + question.options.first().pk, + question.options.last().pk, + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]} + + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + res['positions'][0]['answers'][0]['options'] = [] + res['positions'][0]['answers'][0]['answer'] = file_id_png + question.type = Question.TYPE_FILE + question.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.file + assert answ.answer.startswith("file://") + + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + with scopes_disabled(): + res['positions'][0]['answers'][0]['options'] = [ + question.options.first().pk, + question.options.last().pk, + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.question == question + assert answ.answer == "XL, L" + + question.type = Question.TYPE_NUMBER + question.save() + res['positions'][0]['answers'][0]['options'] = [] + res['positions'][0]['answers'][0]['answer'] = '3.45' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "3.45" + + question.type = Question.TYPE_NUMBER + question.save() + res['positions'][0]['answers'][0]['options'] = [] + res['positions'][0]['answers'][0]['answer'] = 'foo' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['A valid number is required.']}]}]} + + question.type = Question.TYPE_BOOLEAN + question.save() + res['positions'][0]['answers'][0]['options'] = [] + res['positions'][0]['answers'][0]['answer'] = 'True' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "True" + + question.type = Question.TYPE_BOOLEAN + question.save() + res['positions'][0]['answers'][0]['answer'] = '0' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "False" + + question.type = Question.TYPE_BOOLEAN + question.save() + res['positions'][0]['answers'][0]['answer'] = 'bla' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [{'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]}]} + + question.type = Question.TYPE_DATE + question.save() + res['positions'][0]['answers'][0]['answer'] = '2018-05-14' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "2018-05-14" + + question.type = Question.TYPE_DATE + question.save() + res['positions'][0]['answers'][0]['answer'] = 'bla' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'answers': [ + {'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY-MM-DD.']}]}]} + + question.type = Question.TYPE_DATETIME + question.save() + res['positions'][0]['answers'][0]['answer'] = '2018-05-14T13:00:00Z' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "2018-05-14 13:00:00+00:00" + + question.type = Question.TYPE_DATETIME + question.save() + res['positions'][0]['answers'][0]['answer'] = 'bla' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'answers': [{'non_field_errors': [ + 'Datetime has wrong format. Use one of these formats instead: ' + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']}]}]} + + question.type = Question.TYPE_TIME + question.save() + res['positions'][0]['answers'][0]['answer'] = '13:00:00' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.answer == "13:00:00" + + question.type = Question.TYPE_TIME + question.save() + res['positions'][0]['answers'][0]['answer'] = 'bla' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'answers': [ + {'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]}]} + + +@pytest.mark.django_db +def test_order_create_quota_validation(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 1, + "answers": [], + "subevent": None + } + ] + + quota.size = 0 + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, + {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, + ] + } + + quota.size = 1 + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, + ] + } + + res['force'] = True + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_quota_consume_cart(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + with scopes_disabled(): + cr = CartPosition.objects.create( + event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item, + price=23, + expires=now() + datetime.timedelta(hours=3) + ) + + quota.size = 1 + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, + ] + } + + res['consume_carts'] = [cr.cart_id] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert not CartPosition.objects.filter(pk=cr.pk).exists() + + +@pytest.mark.django_db +def test_order_create_quota_consume_cart_expired(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + with scopes_disabled(): + cr = CartPosition.objects.create( + event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item, + price=23, + expires=now() - datetime.timedelta(hours=3) + ) + + quota.size = 0 + quota.save() + res['consume_carts'] = [cr.cart_id] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_free(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'] = [] + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['price'] = '0.00' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.total == Decimal('0.00') + assert o.status == Order.STATUS_PAID + + with scopes_disabled(): + p = o.payments.first() + assert p.provider == "free" + assert p.amount == o.total + assert p.state == "confirmed" + assert o.all_logentries().count() == 2 + + +@pytest.mark.django_db +def test_order_create_invalid_payment_provider(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['payment_provider'] = 'foo' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'payment_provider': ['The given payment provider is not known.']} + + +@pytest.mark.django_db +def test_order_create_invalid_free_order(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['payment_provider'] = 'free' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['You cannot use the "free" payment provider for non-free orders.'] + + +@pytest.mark.django_db +def test_order_create_invalid_status(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['status'] = 'e' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'status': ['"e" is not a valid choice.']} + + +@pytest.mark.django_db +def test_order_create_paid_generate_invoice(token_client, organizer, event, item, quota, question): + event.settings.invoice_generate = 'paid' + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['status'] = 'p' + res['payment_date'] = '2019-04-01 08:20:00Z' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.invoices.count() == 1 + + p = o.payments.first() + assert p.provider == "banktransfer" + assert p.amount == o.total + assert p.state == "confirmed" + assert p.payment_date.year == 2019 + assert p.payment_date.month == 4 + assert p.payment_date.day == 1 + assert p.payment_date.hour == 8 + assert p.payment_date.minute == 20 + + +@pytest.fixture +def seat(event, organizer, item): + SeatingPlan.objects.create( + name="Plan", organizer=organizer, layout="{}" + ) + event.seat_category_mappings.create( + layout_category='Stalls', product=item + ) + return event.seats.create(seat_number="A1", product=item, seat_guid="A1") + + +@pytest.mark.django_db +def test_order_create_with_seat(token_client, organizer, event, item, quota, seat, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.seat == seat + + +@pytest.mark.django_db +def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question): + seat.blocked = True + seat.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + res['sales_channel'] = 'bar' + event.settings.seating_allow_blocked_seats_for_channel = ['bar'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question): + seat.blocked = True + seat.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'seat': ['The selected seat "Seat A1" is not available.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_with_used_seat(token_client, organizer, event, item, quota, seat, question): + CartPosition.objects.create( + event=event, cart_id='aaa', item=item, + price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat + ) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'seat': ['The selected seat "Seat A1" is not available.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_with_unknown_seat(token_client, organizer, event, item, quota, seat, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + '_' + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'seat': ['The specified seat does not exist.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_require_seat(token_client, organizer, event, item, quota, seat, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'seat': ['The specified product requires to choose a seat.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_unseated(token_client, organizer, event, item, quota, seat, question): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + quota.items.add(item2) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item2.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['seat'] = seat.seat_guid + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'seat': ['The specified product does not allow to choose a seat.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_with_duplicate_seat(token_client, organizer, event, item, quota, seat, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None, + "seat": seat.seat_guid + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name_parts": {"full_name": "Peter"}, + "attendee_email": None, + "addon_to": 1, + "answers": [], + "subevent": None, + "seat": seat.seat_guid + } + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + {'seat': ['The selected seat "Seat A1" is not available.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_with_seat_consumed_from_cart(token_client, organizer, event, item, quota, seat, question): + CartPosition.objects.create( + event=event, cart_id='aaa', item=item, + price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat + ) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + res['consume_carts'] = ['aaa'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.seat == seat + + +@pytest.mark.django_db +def test_order_create_send_no_emails(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 0 + + +@pytest.mark.django_db +def test_order_create_send_emails(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['send_email'] = True + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + + +@pytest.mark.django_db +def test_order_create_send_emails_free(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['price'] = '0.00' + res['payment_provider'] = 'free' + del res['fees'] + res['positions'][0]['answers'][0]['question'] = question.pk + res['send_email'] = True + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + + +@pytest.mark.django_db +def test_order_create_send_emails_based_on_sales_channel(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['price'] = '0.00' + res['payment_provider'] = 'free' + del res['fees'] + res['positions'][0]['answers'][0]['question'] = question.pk + res['send_email'] = None + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + + event.settingsmail_sales_channel_placed_paid = [] + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 1 + + +@pytest.mark.django_db +def test_order_create_send_emails_paid(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['send_email'] = True + res['status'] = 'p' + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 2 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + assert djmail.outbox[1].subject == "Payment received for your order: {}".format(resp.data['code']) + + +@pytest.mark.django_db +def test_order_create_send_emails_legacy(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['send_mail'] = True + res['status'] = 'p' + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert len(djmail.outbox) == 2 + assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) + assert djmail.outbox[1].subject == "Payment received for your order: {}".format(resp.data['code']) + + +@pytest.mark.django_db +def test_order_paid_require_payment_method(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['payment_provider'] + res['status'] = 'p' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == [ + 'You cannot create a paid order without a payment provider.' + ] + + res['status'] = "n" + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert not o.payments.exists() + + +@pytest.mark.django_db +def test_order_create_auto_pricing(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.price == item.default_price + assert o.total == item.default_price + Decimal('0.25') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_reverse_charge(token_client, organizer, event, item, quota, question, taxrule): + taxrule.eu_reverse_charge = True + taxrule.home_country = Country('DE') + taxrule.save() + item.tax_rule = taxrule + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['invoice_address']['country'] = 'FR' + res['invoice_address']['is_business'] = True + res['invoice_address']['vat_id'] = 'FR12345' + res['invoice_address']['vat_id_validated'] = True + del res['positions'][0]['price'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.price == Decimal('19.33') + assert p.tax_rate == Decimal('0.00') + assert p.tax_value == Decimal('0.00') + assert o.total == Decimal('19.58') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_country_rate(token_client, organizer, event, item, quota, question, taxrule): + taxrule.eu_reverse_charge = True + taxrule.custom_rules = json.dumps([ + {'country': 'FR', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + taxrule.save() + item.tax_rule = taxrule + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['invoice_address']['country'] = 'FR' + res['invoice_address']['is_business'] = True + res['invoice_address']['vat_id'] = 'FR12345' + res['invoice_address']['vat_id_validated'] = True + del res['positions'][0]['price'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.price == Decimal('38.66') + assert p.tax_rate == Decimal('100.00') + assert p.tax_value == Decimal('19.33') + assert o.total == Decimal('38.91') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_reverse_charge_require_valid_vatid(token_client, organizer, event, item, quota, + question, taxrule): + taxrule.eu_reverse_charge = True + taxrule.home_country = Country('DE') + taxrule.save() + item.tax_rule = taxrule + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['invoice_address']['country'] = 'FR' + res['invoice_address']['is_business'] = True + res['invoice_address']['vat_id'] = 'FR12345' + del res['positions'][0]['price'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.price == Decimal('23.00') + assert p.tax_rate == Decimal('19.00') + + +@pytest.mark.django_db +def test_order_create_autopricing_voucher_budget_partially(token_client, organizer, event, item, quota, question, + taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('2.50'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + del res['positions'][0]['price'] + del res['positions'][0]['positionid'] + res['positions'].append(res['positions'][0]) + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + p2 = o.positions.last() + assert p.price == Decimal('21.50') + assert p2.price == Decimal('22.00') + + +@pytest.mark.django_db +def test_order_create_autopricing_voucher_budget_full(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('0.50'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + del res['positions'][0]['price'] + del res['positions'][0]['positionid'] + res['positions'].append(res['positions'][0]) + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{}, {'voucher': ['The voucher has a remaining budget of 0.00, therefore a ' + 'discount of 1.50 can not be given.']}]} + + +@pytest.mark.django_db +def test_order_create_voucher_budget_exceeded(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('3.00'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + res['positions'][0]['price'] = '19.00' + del res['positions'][0]['positionid'] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'voucher': ['The voucher has a remaining budget of 3.00, therefore a ' + 'discount of 4.00 can not be given.']}]} + + +@pytest.mark.django_db +def test_order_create_voucher_price(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=15, item=item) + res['positions'][0]['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.voucher == voucher + voucher.refresh_from_db() + assert voucher.redeemed == 1 + assert p.price == Decimal('15.00') + assert o.total == Decimal('15.25') + + +@pytest.mark.django_db +def test_order_create_voucher_unknown_code(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + with scopes_disabled(): + event.vouchers.create(price_mode="set", value=15, item=item) + res['positions'][0]['voucher'] = "FOOBAR" + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'voucher': ['Object with code=FOOBAR does not exist.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_voucher_redeemed(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + del res['positions'][0]['price'] + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1) + res['positions'][0]['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'voucher': ['The voucher has already been used the maximum number of times.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_voucher_redeemed_partially(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['item'] = item.pk + del res['positions'][0]['price'] + del res['positions'][0]['positionid'] + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1, max_usages=2) + res['positions'][0]['voucher'] = voucher.code + res['positions'].append(copy.deepcopy(res['positions'][0])) + res['positions'].append(copy.deepcopy(res['positions'][0])) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {}, + {'voucher': ['The voucher has already been used the maximum number of times.']}, + {'voucher': ['The voucher has already been used the maximum number of times.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_voucher_item_mismatch(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + voucher = event.vouchers.create(price_mode="set", value=15, item=item2, redeemed=0) + res['positions'][0]['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'voucher': ['This voucher is not valid for this product.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_voucher_expired(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0, + valid_until=now() - datetime.timedelta(days=1)) + res['positions'][0]['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + 'positions': [ + {'voucher': ['This voucher is expired.']}, + ] + } + + +@pytest.mark.django_db +def test_order_create_voucher_block_quota(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['price'] + quota.size = 0 + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0, + block_quota=True) + res['positions'][0]['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index be2f6f2705..7062d3632b 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -25,10 +25,8 @@ import json from decimal import Decimal from unittest import mock -import freezegun import pytest from django.core import mail as djmail -from django.core.files.base import ContentFile from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -36,15 +34,8 @@ from pytz import UTC from stripe.error import APIConnectionError from tests.plugins.stripe.test_provider import MockedCharge -from pretix.base.models import ( - InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, -) -from pretix.base.models.orders import ( - CartPosition, OrderFee, OrderPayment, OrderRefund, QuestionAnswer, -) -from pretix.base.services.invoices import ( - generate_cancellation, generate_invoice, -) +from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund @pytest.fixture @@ -132,6 +123,7 @@ def order(event, item, taxrule, question): attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w", pseudonymization_id="ABCDEFGHKL", + positionid=1, ) OrderPosition.objects.create( order=o, @@ -141,7 +133,8 @@ def order(event, item, taxrule, question): attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, secret="YBiYJrmF5ufiTLdV1iDf", pseudonymization_id="JKLM", - canceled=True + canceled=True, + positionid=2, ) op.answers.create(question=question, answer='S') return o @@ -290,11 +283,13 @@ def test_order_list_filter_subevent_date(token_client, organizer, event, order, p = order.positions.first() p.subevent = subevent p.save() + fee = order.fees.first() res["positions"][0]["item"] = item.pk res["positions"][0]["subevent"] = subevent.pk res["positions"][0]["answers"][0]["question"] = question.pk res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z') res["fees"][0]["tax_rule"] = taxrule.pk + res["fees"][0]["id"] = fee.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?subevent_after={}'.format( organizer.slug, event.slug, @@ -328,6 +323,7 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi res = dict(TEST_ORDER_RES) with scopes_disabled(): res["positions"][0]["id"] = order.positions.first().pk + res["fees"][0]["id"] = order.fees.first().pk res["positions"][0]["item"] = item.pk res["positions"][0]["answers"][0]["question"] = question.pk res["last_modified"] = order.last_modified.isoformat().replace('+00:00', 'Z') @@ -401,6 +397,7 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques res = dict(TEST_ORDER_RES) with scopes_disabled(): res["positions"][0]["id"] = order.positions.first().pk + res["fees"][0]["id"] = order.fees.first().pk res["positions"][0]["item"] = item.pk res["fees"][0]["tax_rule"] = taxrule.pk res["positions"][0]["answers"][0]["question"] = question.pk @@ -974,179 +971,6 @@ def test_orderposition_delete(token_client, organizer, event, order, item, quest assert order.total == Decimal('23.25') -@pytest.fixture -def invoice(order): - testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC) - - with mock.patch('django.utils.timezone.now') as mock_now: - mock_now.return_value = testtime - return generate_invoice(order) - - -TEST_INVOICE_RES = { - "order": "FOO", - "number": "DUMMY-00001", - "is_cancellation": False, - "invoice_from_name": "", - "invoice_from": "", - "invoice_from_zipcode": "", - "invoice_from_city": "", - "invoice_from_country": None, - "invoice_from_tax_id": "", - "invoice_from_vat_id": "", - "invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123", - "invoice_to_company": "Sample company", - "invoice_to_name": "", - "invoice_to_street": "", - "invoice_to_zipcode": "", - "invoice_to_city": "", - "invoice_to_state": "", - "invoice_to_country": "NZ", - "invoice_to_vat_id": "DE123", - "invoice_to_beneficiary": "", - "custom_field": None, - "date": "2017-12-10", - "refers": None, - "locale": "en", - "introductory_text": "", - "internal_reference": "", - "additional_text": "", - "payment_provider_text": "", - "footer_text": "", - "foreign_currency_display": None, - "foreign_currency_rate": None, - "foreign_currency_rate_date": None, - "lines": [ - { - "position": 1, - "description": "Budget Ticket
Attendee: Peter", - 'subevent': None, - 'event_date_from': '2017-12-27T10:00:00Z', - 'event_date_to': None, - 'event_location': None, - 'attendee_name': 'Peter', - 'item': None, - 'variation': None, - 'fee_type': None, - 'fee_internal_type': None, - "gross_value": "23.00", - "tax_value": "0.00", - "tax_name": "", - "tax_rate": "0.00" - }, - { - "position": 2, - "description": "Payment fee", - 'subevent': None, - 'event_date_from': '2017-12-27T10:00:00Z', - 'event_date_to': None, - 'event_location': None, - 'attendee_name': None, - 'fee_type': "payment", - 'fee_internal_type': None, - 'item': None, - 'variation': None, - "gross_value": "0.25", - "tax_value": "0.05", - "tax_name": "", - "tax_rate": "19.00" - } - ] -} - - -@pytest.mark.django_db -def test_invoice_list(token_client, organizer, event, order, item, invoice): - res = dict(TEST_INVOICE_RES) - res['lines'][0]['item'] = item.pk - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/'.format(organizer.slug, event.slug)) - assert resp.status_code == 200 - assert [res] == resp.data['results'] - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO'.format(organizer.slug, event.slug)) - assert [res] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=BAR'.format(organizer.slug, event.slug)) - assert [] == resp.data['results'] - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number={}'.format( - organizer.slug, event.slug, invoice.number)) - assert [res] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number=XXX'.format( - organizer.slug, event.slug)) - assert [] == resp.data['results'] - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=en'.format( - organizer.slug, event.slug)) - assert [res] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=de'.format( - organizer.slug, event.slug)) - assert [] == resp.data['results'] - - with scopes_disabled(): - ic = generate_cancellation(invoice) - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=false'.format( - organizer.slug, event.slug)) - assert [res] == resp.data['results'] - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=true'.format( - organizer.slug, event.slug)) - assert len(resp.data['results']) == 1 - assert resp.data['results'][0]['number'] == ic.number - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( - organizer.slug, event.slug, invoice.number)) - assert len(resp.data['results']) == 1 - assert resp.data['results'][0]['number'] == ic.number - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( - organizer.slug, event.slug, ic.number)) - assert [] == resp.data['results'] - - -@pytest.mark.django_db -def test_invoice_detail(token_client, organizer, event, item, invoice): - res = dict(TEST_INVOICE_RES) - res['lines'][0]['item'] = item.pk - - resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/{}/'.format(organizer.slug, event.slug, - invoice.number)) - assert resp.status_code == 200 - assert res == resp.data - - -@pytest.mark.django_db -def test_invoice_regenerate(token_client, organizer, event, invoice): - organizer.settings.invoice_regenerate_allowed = True - with scopes_disabled(): - InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") - - with freezegun.freeze_time("2017-12-10"): - resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/regenerate/'.format( - organizer.slug, event.slug, invoice.number - )) - assert resp.status_code == 204 - invoice.refresh_from_db() - assert "ACME Ltd" in invoice.invoice_to - - -@pytest.mark.django_db -def test_invoice_reissue(token_client, organizer, event, invoice): - with scopes_disabled(): - InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") - - resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/reissue/'.format( - organizer.slug, event.slug, invoice.number - )) - assert resp.status_code == 204 - invoice.refresh_from_db() - assert "ACME Ltd" not in invoice.invoice_to - with scopes_disabled(): - assert invoice.order.invoices.count() == 3 - invoice = invoice.order.invoices.last() - assert "ACME Ltd" in invoice.invoice_to - - @pytest.mark.django_db def test_order_mark_paid_pending(token_client, organizer, event, order): resp = token_client.post( @@ -1624,2386 +1448,6 @@ def test_order_invalid_state_deny(token_client, organizer, event, order): assert resp.status_code == 400 -ORDER_CREATE_PAYLOAD = { - "email": "dummy@dummy.test", - "phone": "+49622112345", - "locale": "en", - "sales_channel": "web", - "fees": [ - { - "fee_type": "payment", - "value": "0.25", - "description": "", - "internal_type": "", - "tax_rule": None - } - ], - "payment_provider": "banktransfer", - "invoice_address": { - "is_business": False, - "company": "Sample company", - "name_parts": {"full_name": "Fo"}, - "street": "Bar", - "state": "", - "zipcode": "", - "city": "Sample City", - "country": "NZ", - "internal_reference": "", - "vat_id": "" - }, - "positions": [ - { - "positionid": 1, - "item": 1, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "company": "FOOCORP", - "answers": [ - { - "question": 1, - "answer": "S", - "options": [] - } - ], - "subevent": None - } - ], -} - - -@pytest.mark.django_db -def test_order_create(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - with scopes_disabled(): - customer = organizer.customers.create() - res['customer'] = customer.identifier - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.customer == customer - assert o.email == "dummy@dummy.test" - assert o.phone == "+49622112345" - assert o.locale == "en" - assert o.total == Decimal('23.25') - assert o.status == Order.STATUS_PENDING - assert o.sales_channel == "web" - assert not o.testmode - - with scopes_disabled(): - p = o.payments.first() - assert p.provider == "banktransfer" - assert p.amount == o.total - assert p.state == "created" - - with scopes_disabled(): - fee = o.fees.first() - assert fee.fee_type == "payment" - assert fee.value == Decimal('0.25') - ia = o.invoice_address - assert ia.company == "Sample company" - assert ia.name_parts == {"full_name": "Fo", "_scheme": "full"} - assert ia.name_cached == "Fo" - with scopes_disabled(): - assert o.positions.count() == 1 - pos = o.positions.first() - assert pos.item == item - assert pos.price == Decimal("23.00") - assert pos.attendee_name_parts == {"full_name": "Peter", "_scheme": "full"} - assert pos.company == "FOOCORP" - with scopes_disabled(): - answ = pos.answers.first() - assert answ.question == question - assert answ.answer == "S" - with scopes_disabled(): - assert o.transactions.count() == 2 - - -@pytest.mark.django_db -def test_order_create_simulate(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - question.type = Question.TYPE_CHOICE_MULTIPLE - question.save() - with scopes_disabled(): - opt = question.options.create(answer="L") - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['answers'][0]['options'] = [opt.pk] - res['simulate'] = True - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - assert Order.objects.count() == 0 - assert QuestionAnswer.objects.count() == 0 - assert OrderPosition.objects.count() == 0 - assert OrderFee.objects.count() == 0 - assert InvoiceAddress.objects.count() == 0 - d = resp.data - del d['last_modified'] - del d['secret'] - del d['url'] - del d['expires'] - del d['invoice_address']['last_modified'] - del d['positions'][0]['secret'] - assert d == { - 'code': 'PREVIEW', - 'status': 'n', - 'testmode': False, - 'email': 'dummy@dummy.test', - 'phone': '+49622112345', - 'customer': None, - 'locale': 'en', - 'datetime': None, - 'payment_date': None, - 'payment_provider': None, - 'fees': [ - { - 'fee_type': 'payment', - 'value': '0.25', - 'description': '', - 'internal_type': '', - 'tax_rate': '0.00', - 'tax_value': '0.00', - 'tax_rule': None, - 'canceled': False - } - ], - 'total': '23.25', - 'comment': '', - "custom_followup_at": None, - 'invoice_address': { - 'is_business': False, - 'company': 'Sample company', - 'name': 'Fo', - 'name_parts': {'full_name': 'Fo', '_scheme': 'full'}, - 'street': 'Bar', - 'zipcode': '', - 'city': 'Sample City', - 'country': 'NZ', - 'state': '', - 'vat_id': '', - 'vat_id_validated': False, - 'internal_reference': '' - }, - 'positions': [ - { - 'id': 0, - 'order': '', - 'positionid': 1, - 'item': item.pk, - 'variation': None, - 'price': '23.00', - 'attendee_name': 'Peter', - 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, - 'attendee_email': None, - 'voucher': None, - 'tax_rate': '0.00', - 'tax_value': '0.00', - 'addon_to': None, - 'subevent': None, - 'checkins': [], - 'downloads': [], - 'answers': [ - {'question': question.pk, 'answer': 'L', 'question_identifier': 'ABC', - 'options': [opt.pk], - 'option_identifiers': [opt.identifier]} - ], - 'tax_rule': None, - 'pseudonymization_id': 'PREVIEW', - 'seat': None, - 'company': "FOOCORP", - 'street': None, - 'city': None, - 'zipcode': None, - 'state': None, - 'country': None, - 'canceled': False - } - ], - 'downloads': [], - 'checkin_attention': False, - 'payments': [], - 'refunds': [], - 'require_approval': False, - 'sales_channel': 'web', - } - - -@pytest.mark.django_db -def test_order_create_positionids_addons_simulated(token_client, organizer, event, item, quota): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None - }, - { - "positionid": 2, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 1, - "answers": [], - "subevent": None - } - ] - res['simulate'] = True - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - del resp.data['positions'][0]['secret'] - del resp.data['positions'][1]['secret'] - assert [dict(f) for f in resp.data['positions']] == [ - {'id': 0, 'order': '', 'positionid': 1, 'item': item.pk, 'variation': None, 'price': '23.00', - 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, - 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, - 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', - 'addon_to': None, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None, - 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False}, - {'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00', - 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, - 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, - 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', - 'addon_to': 1, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None, - 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False} - ] - - -@pytest.mark.django_db -def test_order_create_autocheckin(token_client, organizer, event, item, quota, question, clist_autocheckin): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert "web" in clist_autocheckin.auto_checkin_sales_channels - assert o.positions.first().checkins.first().auto_checked_in - - clist_autocheckin.auto_checkin_sales_channels = [] - clist_autocheckin.save() - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert clist_autocheckin.auto_checkin_sales_channels == [] - assert o.positions.first().checkins.count() == 0 - - -@pytest.mark.django_db -def test_order_create_require_approval(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['require_approval'] = True - res['send_email'] = True - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.require_approval - assert len(djmail.outbox) == 1 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - assert "approval" in djmail.outbox[0].body - - -@pytest.mark.django_db -def test_order_create_invoice_address_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['invoice_address'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - with pytest.raises(InvoiceAddress.DoesNotExist): - o.invoice_address - - -@pytest.mark.django_db -def test_order_create_sales_channel_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['sales_channel'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.sales_channel == "web" - - -@pytest.mark.django_db -def test_order_create_sales_channel_invalid(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['sales_channel'] = 'foo' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'sales_channel': ['Unknown sales channel.']} - - -@pytest.mark.django_db -def test_order_create_in_test_mode(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['testmode'] = True - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.testmode - - -@pytest.mark.django_db -def test_order_create_in_test_mode_saleschannel_limited(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['testmode'] = True - res['sales_channel'] = 'baz' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'testmode': ['This sales channel does not provide support for test mode.']} - - -@pytest.mark.django_db -def test_order_create_attendee_name_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['attendee_name'] = None - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['attendee_name_parts'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.positions.first().attendee_name_parts == {} - - -@pytest.mark.django_db -def test_order_create_legacy_attendee_name(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['attendee_name'] = 'Peter' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - del res['positions'][0]['attendee_name_parts'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.positions.first().attendee_name_parts == {"_legacy": "Peter"} - - -@pytest.mark.django_db -def test_order_create_legacy_invoice_name(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['invoice_address']['name'] = 'Peter' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - del res['invoice_address']['name_parts'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.invoice_address.name_parts == {"_legacy": "Peter"} - - -@pytest.mark.django_db -def test_order_create_code_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['code'] = 'ABCDE' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.code == "ABCDE" - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'code': ['This order code is already in use.']} - - res['code'] = 'ABaDE' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'code': ['This order code contains invalid characters.']} - - -@pytest.mark.django_db -def test_order_email_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['email'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert not o.email - - -@pytest.mark.django_db -def test_order_create_payment_provider_optional_free(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['price'] = '0.00' - res['positions'][0]['status'] = 'p' - del res['payment_provider'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert not o.payments.exists() - - -@pytest.mark.django_db -def test_order_create_payment_info_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - res['payment_info'] = { - 'foo': { - 'bar': [1, 2], - 'test': False - } - } - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - - p = o.payments.first() - assert p.provider == "banktransfer" - assert p.amount == o.total - assert json.loads(p.info) == res['payment_info'] - - -@pytest.mark.django_db -def test_order_create_position_secret_optional(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.positions.first().secret - - res['positions'][0]['secret'] = "aaa" - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.positions.first().secret == "aaa" - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - - assert resp.data == {'positions': [{'secret': ['You cannot assign a position secret that already exists.']}]} - - -@pytest.mark.django_db -def test_order_create_tax_rules(token_client, organizer, event, item, quota, question, taxrule): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['tax_rule'] = taxrule.pk - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - item.tax_rule = taxrule - item.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - fee = o.fees.first() - assert fee.fee_type == "payment" - assert fee.value == Decimal('0.25') - assert fee.tax_rate == Decimal('19.00') - assert fee.tax_rule == taxrule - ia = o.invoice_address - assert ia.company == "Sample company" - with scopes_disabled(): - pos = o.positions.first() - assert pos.item == item - assert pos.tax_rate == Decimal('19.00') - assert pos.tax_value == Decimal('3.67') - assert pos.tax_rule == taxrule - - -@pytest.mark.django_db -def test_order_create_fee_type_validation(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['fee_type'] = 'unknown' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'fees': [{'fee_type': ['"unknown" is not a valid choice.']}]} - - -@pytest.mark.django_db -def test_order_create_fee_as_percentage(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['_treat_value_as_percentage'] = True - res['fees'][0]['value'] = '10.00' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - fee = o.fees.first() - assert fee.value == Decimal('2.30') - assert o.total == Decimal('25.30') - - -@pytest.mark.django_db -def test_order_create_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['_split_taxes_like_products'] = True - res['fees'][0]['_treat_value_as_percentage'] = True - res['fees'][0]['value'] = '10.00' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - item.tax_rule = taxrule - item.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - fee = o.fees.first() - assert fee.value == Decimal('2.30') - assert fee.tax_rate == Decimal('19.00') - assert o.total == Decimal('25.30') - - -@pytest.mark.django_db -def test_order_create_negative_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['_split_taxes_like_products'] = True - res['fees'][0]['value'] = '-10.00' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - item.tax_rule = taxrule - item.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - fee = o.fees.first() - assert fee.value == Decimal('-10.00') - assert fee.tax_value == Decimal('-1.60') - assert fee.tax_rate == Decimal('19.00') - assert o.total == Decimal('13.00') - - -@pytest.mark.django_db -def test_order_create_tax_rule_wrong_event(token_client, organizer, event, item, quota, question, taxrule2): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'][0]['tax_rule'] = taxrule2.pk - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'fees': [{'tax_rule': ['The specified tax rate does not belong to this event.']}]} - - -@pytest.mark.django_db -def test_order_create_subevent_not_allowed(token_client, organizer, event, item, quota, question, subevent2): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['subevent'] = subevent2.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'subevent': ['You cannot set a subevent for this event.']}]} - - -@pytest.mark.django_db -def test_order_create_empty(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': ['An order cannot be empty.']} - - -@pytest.mark.django_db -def test_order_create_subevent_validation(token_client, organizer, event, item, subevent, subevent2, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'subevent': ['You need to set a subevent.']}]} - - res['positions'][0]['subevent'] = subevent2.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'subevent': ['The specified subevent does not belong to this event.']}]} - - -@pytest.mark.django_db -def test_order_create_item_validation(token_client, organizer, event, item, item2, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - item.active = False - item.save() - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'item': ['The specified item is not active.']}]} - item.active = True - item.save() - - res['positions'][0]['item'] = item2.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'item': ['The specified item does not belong to this event.']}]} - - with scopes_disabled(): - var2 = item2.variations.create(value="A") - quota.variations.add(var2) - - res['positions'][0]['item'] = item.pk - res['positions'][0]['variation'] = var2.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'variation': ['You cannot specify a variation for this item.']}]} - - with scopes_disabled(): - var1 = item.variations.create(value="A") - res['positions'][0]['item'] = item.pk - res['positions'][0]['variation'] = var1.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not assigned to a quota.']}]} - - with scopes_disabled(): - quota.variations.add(var1) - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - res['positions'][0]['variation'] = var2.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [{'variation': ['The specified variation does not belong to the specified item.']}]} - - res['positions'][0]['variation'] = None - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'variation': ['You should specify a variation for this item.']}]} - - -@pytest.mark.django_db -def test_order_create_subevent_disabled(token_client, organizer, event, item, subevent, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['subevent'] = subevent.pk - s = item.subeventitem_set.create(subevent=subevent, disabled=True) - quota.subevent = subevent - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} - - s.delete() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - -@pytest.mark.django_db -def test_order_create_subevent_variation_disabled(token_client, organizer, event, item, subevent, quota, question): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - var = item2.variations.create(default_price=12, value="XS") - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item2.pk - res['positions'][0]['variation'] = var.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['subevent'] = subevent.pk - s = var.subeventitemvariation_set.create(subevent=subevent, disabled=True) - quota.subevent = subevent - quota.items.add(item2) - quota.variations.add(var) - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} - - s.delete() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - -@pytest.mark.django_db -def test_order_create_positionids_addons(token_client, organizer, event, item, quota): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None - }, - { - "positionid": 2, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 1, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos1 = o.positions.first() - pos2 = o.positions.last() - assert pos2.addon_to == pos1 - - -@pytest.mark.django_db -def test_order_create_positionid_validation(token_client, organizer, event, item, quota): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None - }, - { - "positionid": 2, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 2, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - { - 'addon_to': [ - 'If you set addon_to, you need to make sure that the ' - 'referenced position ID exists and is transmitted directly ' - 'before its add-ons.' - ] - } - ] - } - - res['positions'] = [ - { - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None - }, - { - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 2, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [ - {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}, - {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} - ]} - - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - }, - { - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - { - 'positionid': ['If you set position IDs manually, you need to do so for all positions.'] - } - ] - } - - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - }, - { - "positionid": 3, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - { - 'positionid': ['Position IDs need to be consecutive.'] - } - ] - } - - res['positions'] = [ - { - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - }, - { - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "answers": [], - "subevent": None - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.positions.first().positionid == 1 - assert o.positions.last().positionid == 2 - - -@pytest.mark.django_db -def test_order_create_answer_validation(token_client, organizer, event, item, quota, question, question2): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question2.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [{'answers': [{'question': ['The specified question does not belong to this event.']}]}]} - - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['answers'][0]['options'] = [question.options.first().pk] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'answers': [ - {'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]}]} - - question.type = Question.TYPE_CHOICE - question.save() - res['positions'][0]['answers'][0]['options'] = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [ - {'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]} - - with scopes_disabled(): - question2.options.create(answer="L") - with scopes_disabled(): - res['positions'][0]['answers'][0]['options'] = [ - question2.options.first().pk, - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [{'answers': [{'non_field_errors': ['The specified option does not belong to this question.']}]}]} - - with scopes_disabled(): - question.options.create(answer="L") - with scopes_disabled(): - res['positions'][0]['answers'][0]['options'] = [ - question.options.first().pk, - question.options.last().pk, - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]} - - r = token_client.post( - '/api/v1/upload', - data={ - 'media_type': 'image/png', - 'file': ContentFile('file.png', 'invalid png content') - }, - format='upload', - HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', - ) - assert r.status_code == 201 - file_id_png = r.data['id'] - res['positions'][0]['answers'][0]['options'] = [] - res['positions'][0]['answers'][0]['answer'] = file_id_png - question.type = Question.TYPE_FILE - question.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.file - assert answ.answer.startswith("file://") - - question.type = Question.TYPE_CHOICE_MULTIPLE - question.save() - with scopes_disabled(): - res['positions'][0]['answers'][0]['options'] = [ - question.options.first().pk, - question.options.last().pk, - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.question == question - assert answ.answer == "XL, L" - - question.type = Question.TYPE_NUMBER - question.save() - res['positions'][0]['answers'][0]['options'] = [] - res['positions'][0]['answers'][0]['answer'] = '3.45' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "3.45" - - question.type = Question.TYPE_NUMBER - question.save() - res['positions'][0]['answers'][0]['options'] = [] - res['positions'][0]['answers'][0]['answer'] = 'foo' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['A valid number is required.']}]}]} - - question.type = Question.TYPE_BOOLEAN - question.save() - res['positions'][0]['answers'][0]['options'] = [] - res['positions'][0]['answers'][0]['answer'] = 'True' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "True" - - question.type = Question.TYPE_BOOLEAN - question.save() - res['positions'][0]['answers'][0]['answer'] = '0' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "False" - - question.type = Question.TYPE_BOOLEAN - question.save() - res['positions'][0]['answers'][0]['answer'] = 'bla' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [{'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]}]} - - question.type = Question.TYPE_DATE - question.save() - res['positions'][0]['answers'][0]['answer'] = '2018-05-14' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "2018-05-14" - - question.type = Question.TYPE_DATE - question.save() - res['positions'][0]['answers'][0]['answer'] = 'bla' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'answers': [ - {'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY-MM-DD.']}]}]} - - question.type = Question.TYPE_DATETIME - question.save() - res['positions'][0]['answers'][0]['answer'] = '2018-05-14T13:00:00Z' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "2018-05-14 13:00:00+00:00" - - question.type = Question.TYPE_DATETIME - question.save() - res['positions'][0]['answers'][0]['answer'] = 'bla' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'answers': [{'non_field_errors': [ - 'Datetime has wrong format. Use one of these formats instead: ' - 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']}]}]} - - question.type = Question.TYPE_TIME - question.save() - res['positions'][0]['answers'][0]['answer'] = '13:00:00' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - pos = o.positions.first() - answ = pos.answers.first() - assert answ.answer == "13:00:00" - - question.type = Question.TYPE_TIME - question.save() - res['positions'][0]['answers'][0]['answer'] = 'bla' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'answers': [ - {'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]}]} - - -@pytest.mark.django_db -def test_order_create_quota_validation(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None - }, - { - "positionid": 2, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 1, - "answers": [], - "subevent": None - } - ] - - quota.size = 0 - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, - {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, - ] - } - - quota.size = 1 - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, - ] - } - - res['force'] = True - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - -@pytest.mark.django_db -def test_order_create_quota_consume_cart(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - - with scopes_disabled(): - cr = CartPosition.objects.create( - event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item, - price=23, - expires=now() + datetime.timedelta(hours=3) - ) - - quota.size = 1 - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, - ] - } - - res['consume_carts'] = [cr.cart_id] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - assert not CartPosition.objects.filter(pk=cr.pk).exists() - - -@pytest.mark.django_db -def test_order_create_quota_consume_cart_expired(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - - with scopes_disabled(): - cr = CartPosition.objects.create( - event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item, - price=23, - expires=now() - datetime.timedelta(hours=3) - ) - - quota.size = 0 - quota.save() - res['consume_carts'] = [cr.cart_id] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_free(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['fees'] = [] - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['price'] = '0.00' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.total == Decimal('0.00') - assert o.status == Order.STATUS_PAID - - with scopes_disabled(): - p = o.payments.first() - assert p.provider == "free" - assert p.amount == o.total - assert p.state == "confirmed" - assert o.all_logentries().count() == 2 - - -@pytest.mark.django_db -def test_order_create_invalid_payment_provider(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['payment_provider'] = 'foo' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'payment_provider': ['The given payment provider is not known.']} - - -@pytest.mark.django_db -def test_order_create_invalid_free_order(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['payment_provider'] = 'free' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == ['You cannot use the "free" payment provider for non-free orders.'] - - -@pytest.mark.django_db -def test_order_create_invalid_status(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['status'] = 'e' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'status': ['"e" is not a valid choice.']} - - -@pytest.mark.django_db -def test_order_create_paid_generate_invoice(token_client, organizer, event, item, quota, question): - event.settings.invoice_generate = 'paid' - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['status'] = 'p' - res['payment_date'] = '2019-04-01 08:20:00Z' - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert o.invoices.count() == 1 - - p = o.payments.first() - assert p.provider == "banktransfer" - assert p.amount == o.total - assert p.state == "confirmed" - assert p.payment_date.year == 2019 - assert p.payment_date.month == 4 - assert p.payment_date.day == 1 - assert p.payment_date.hour == 8 - assert p.payment_date.minute == 20 - - -@pytest.fixture -def seat(event, organizer, item): - SeatingPlan.objects.create( - name="Plan", organizer=organizer, layout="{}" - ) - event.seat_category_mappings.create( - layout_category='Stalls', product=item - ) - return event.seats.create(seat_number="A1", product=item, seat_guid="A1") - - -@pytest.mark.django_db -def test_order_create_with_seat(token_client, organizer, event, item, quota, seat, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.seat == seat - - -@pytest.mark.django_db -def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question): - seat.blocked = True - seat.save() - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid - res['positions'][0]['answers'][0]['question'] = question.pk - res['sales_channel'] = 'bar' - event.settings.seating_allow_blocked_seats_for_channel = ['bar'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - -@pytest.mark.django_db -def test_order_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question): - seat.blocked = True - seat.save() - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'seat': ['The selected seat "Seat A1" is not available.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_with_used_seat(token_client, organizer, event, item, quota, seat, question): - CartPosition.objects.create( - event=event, cart_id='aaa', item=item, - price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat - ) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'seat': ['The selected seat "Seat A1" is not available.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_with_unknown_seat(token_client, organizer, event, item, quota, seat, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid + '_' - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'seat': ['The specified seat does not exist.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_require_seat(token_client, organizer, event, item, quota, seat, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'seat': ['The specified product requires to choose a seat.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_unseated(token_client, organizer, event, item, quota, seat, question): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - quota.items.add(item2) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item2.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['seat'] = seat.seat_guid - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'seat': ['The specified product does not allow to choose a seat.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_with_duplicate_seat(token_client, organizer, event, item, quota, seat, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'] = [ - { - "positionid": 1, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": None, - "answers": [], - "subevent": None, - "seat": seat.seat_guid - }, - { - "positionid": 2, - "item": item.pk, - "variation": None, - "price": "23.00", - "attendee_name_parts": {"full_name": "Peter"}, - "attendee_email": None, - "addon_to": 1, - "answers": [], - "subevent": None, - "seat": seat.seat_guid - } - ] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - {'seat': ['The selected seat "Seat A1" is not available.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_with_seat_consumed_from_cart(token_client, organizer, event, item, quota, seat, question): - CartPosition.objects.create( - event=event, cart_id='aaa', item=item, - price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat - ) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['seat'] = seat.seat_guid - res['positions'][0]['answers'][0]['question'] = question.pk - res['consume_carts'] = ['aaa'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.seat == seat - - -@pytest.mark.django_db -def test_order_create_send_no_emails(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 0 - - -@pytest.mark.django_db -def test_order_create_send_emails(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['send_email'] = True - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 1 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - - -@pytest.mark.django_db -def test_order_create_send_emails_free(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['price'] = '0.00' - res['payment_provider'] = 'free' - del res['fees'] - res['positions'][0]['answers'][0]['question'] = question.pk - res['send_email'] = True - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 1 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - - -@pytest.mark.django_db -def test_order_create_send_emails_based_on_sales_channel(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['price'] = '0.00' - res['payment_provider'] = 'free' - del res['fees'] - res['positions'][0]['answers'][0]['question'] = question.pk - res['send_email'] = None - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 1 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - - event.settingsmail_sales_channel_placed_paid = [] - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 1 - - -@pytest.mark.django_db -def test_order_create_send_emails_paid(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['send_email'] = True - res['status'] = 'p' - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 2 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - assert djmail.outbox[1].subject == "Payment received for your order: {}".format(resp.data['code']) - - -@pytest.mark.django_db -def test_order_create_send_emails_legacy(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['send_mail'] = True - res['status'] = 'p' - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - assert len(djmail.outbox) == 2 - assert djmail.outbox[0].subject == "Your order: {}".format(resp.data['code']) - assert djmail.outbox[1].subject == "Payment received for your order: {}".format(resp.data['code']) - - -@pytest.mark.django_db -def test_order_paid_require_payment_method(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['payment_provider'] - res['status'] = 'p' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == [ - 'You cannot create a paid order without a payment provider.' - ] - - res['status'] = "n" - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - assert not o.payments.exists() - - -@pytest.mark.django_db -def test_order_create_auto_pricing(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.price == item.default_price - assert o.total == item.default_price + Decimal('0.25') - - -@pytest.mark.django_db -def test_order_create_auto_pricing_reverse_charge(token_client, organizer, event, item, quota, question, taxrule): - taxrule.eu_reverse_charge = True - taxrule.home_country = Country('DE') - taxrule.save() - item.tax_rule = taxrule - item.save() - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['invoice_address']['country'] = 'FR' - res['invoice_address']['is_business'] = True - res['invoice_address']['vat_id'] = 'FR12345' - res['invoice_address']['vat_id_validated'] = True - del res['positions'][0]['price'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.price == Decimal('19.33') - assert p.tax_rate == Decimal('0.00') - assert p.tax_value == Decimal('0.00') - assert o.total == Decimal('19.58') - - -@pytest.mark.django_db -def test_order_create_auto_pricing_country_rate(token_client, organizer, event, item, quota, question, taxrule): - taxrule.eu_reverse_charge = True - taxrule.custom_rules = json.dumps([ - {'country': 'FR', 'address_type': '', 'action': 'vat', 'rate': '100.00'} - ]) - taxrule.save() - item.tax_rule = taxrule - item.save() - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['invoice_address']['country'] = 'FR' - res['invoice_address']['is_business'] = True - res['invoice_address']['vat_id'] = 'FR12345' - res['invoice_address']['vat_id_validated'] = True - del res['positions'][0]['price'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.price == Decimal('38.66') - assert p.tax_rate == Decimal('100.00') - assert p.tax_value == Decimal('19.33') - assert o.total == Decimal('38.91') - - -@pytest.mark.django_db -def test_order_create_auto_pricing_reverse_charge_require_valid_vatid(token_client, organizer, event, item, quota, - question, taxrule): - taxrule.eu_reverse_charge = True - taxrule.home_country = Country('DE') - taxrule.save() - item.tax_rule = taxrule - item.save() - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['invoice_address']['country'] = 'FR' - res['invoice_address']['is_business'] = True - res['invoice_address']['vat_id'] = 'FR12345' - del res['positions'][0]['price'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.price == Decimal('23.00') - assert p.tax_rate == Decimal('19.00') - - -@pytest.mark.django_db -def test_order_create_autopricing_voucher_budget_partially(token_client, organizer, event, item, quota, question, - taxrule): - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('2.50'), - max_usages=999) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['voucher'] = voucher.code - del res['positions'][0]['price'] - del res['positions'][0]['positionid'] - res['positions'].append(res['positions'][0]) - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - print(resp.data) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - p2 = o.positions.last() - assert p.price == Decimal('21.50') - assert p2.price == Decimal('22.00') - - -@pytest.mark.django_db -def test_order_create_autopricing_voucher_budget_full(token_client, organizer, event, item, quota, question, taxrule): - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('0.50'), - max_usages=999) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['voucher'] = voucher.code - del res['positions'][0]['price'] - del res['positions'][0]['positionid'] - res['positions'].append(res['positions'][0]) - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == {'positions': [{}, {'voucher': ['The voucher has a remaining budget of 0.00, therefore a ' - 'discount of 1.50 can not be given.']}]} - - -@pytest.mark.django_db -def test_order_create_voucher_budget_exceeded(token_client, organizer, event, item, quota, question, taxrule): - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('3.00'), - max_usages=999) - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['voucher'] = voucher.code - res['positions'][0]['price'] = '19.00' - del res['positions'][0]['positionid'] - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - print(resp.data) - assert resp.status_code == 400 - assert resp.data == {'positions': [{'voucher': ['The voucher has a remaining budget of 3.00, therefore a ' - 'discount of 4.00 can not be given.']}]} - - -@pytest.mark.django_db -def test_order_create_voucher_price(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=15, item=item) - res['positions'][0]['voucher'] = voucher.code - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - with scopes_disabled(): - o = Order.objects.get(code=resp.data['code']) - p = o.positions.first() - assert p.voucher == voucher - voucher.refresh_from_db() - assert voucher.redeemed == 1 - assert p.price == Decimal('15.00') - assert o.total == Decimal('15.25') - - -@pytest.mark.django_db -def test_order_create_voucher_unknown_code(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - with scopes_disabled(): - event.vouchers.create(price_mode="set", value=15, item=item) - res['positions'][0]['voucher'] = "FOOBAR" - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'voucher': ['Object with code=FOOBAR does not exist.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_voucher_redeemed(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - del res['positions'][0]['price'] - res['positions'][0]['answers'][0]['question'] = question.pk - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1) - res['positions'][0]['voucher'] = voucher.code - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'voucher': ['The voucher has already been used the maximum number of times.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_voucher_redeemed_partially(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['answers'][0]['question'] = question.pk - res['positions'][0]['item'] = item.pk - del res['positions'][0]['price'] - del res['positions'][0]['positionid'] - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1, max_usages=2) - res['positions'][0]['voucher'] = voucher.code - res['positions'].append(copy.deepcopy(res['positions'][0])) - res['positions'].append(copy.deepcopy(res['positions'][0])) - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {}, - {'voucher': ['The voucher has already been used the maximum number of times.']}, - {'voucher': ['The voucher has already been used the maximum number of times.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_voucher_item_mismatch(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - voucher = event.vouchers.create(price_mode="set", value=15, item=item2, redeemed=0) - res['positions'][0]['voucher'] = voucher.code - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'voucher': ['This voucher is not valid for this product.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_voucher_expired(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0, - valid_until=now() - datetime.timedelta(days=1)) - res['positions'][0]['voucher'] = voucher.code - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - assert resp.data == { - 'positions': [ - {'voucher': ['This voucher is expired.']}, - ] - } - - -@pytest.mark.django_db -def test_order_create_voucher_block_quota(token_client, organizer, event, item, quota, question): - res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - res['positions'][0]['item'] = item.pk - res['positions'][0]['answers'][0]['question'] = question.pk - del res['positions'][0]['price'] - quota.size = 0 - quota.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - - with scopes_disabled(): - voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0, - block_quota=True) - res['positions'][0]['voucher'] = voucher.code - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 201 - - REFUND_CREATE_PAYLOAD = { "state": "created", "provider": "manual", @@ -4197,634 +1641,6 @@ def test_order_delete_test_mode_voucher_cancelled_order(token_client, organizer, assert voucher.redeemed == 42 -@pytest.mark.django_db -def test_order_update_ignore_fields(token_client, organizer, event, order): - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'status': 'c' - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert order.status == 'n' - - -@pytest.mark.django_db -def test_order_update_only_partial(token_client, organizer, event, order): - resp = token_client.put( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'status': 'c' - } - ) - assert resp.status_code == 405 - - -@pytest.mark.django_db -def test_order_update_state_validation(token_client, organizer, event, order): - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "John Doe", - "name_parts": {}, - "street": "", - "state": "", - "zipcode": "", - "city": "Paris", - "country": "NONEXISTANT", - "internal_reference": "", - "vat_id": "", - } - } - ) - assert resp.status_code == 400 - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "John Doe", - "name_parts": {}, - "street": "", - "state": "NONEXISTANT", - "zipcode": "", - "city": "Test", - "country": "AU", - "internal_reference": "", - "vat_id": "", - } - } - ) - assert resp.status_code == 400 - - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "John Doe", - "name_parts": {}, - "street": "", - "state": "QLD", - "zipcode": "", - "city": "Test", - "country": "AU", - "internal_reference": "", - "vat_id": "", - } - } - ) - assert resp.status_code == 200 - order.invoice_address.refresh_from_db() - assert order.invoice_address.state == "QLD" - assert order.invoice_address.country == "AU" - - -@pytest.mark.django_db -def test_order_update_allowed_fields(token_client, organizer, event, order): - event.settings.locales = ['de', 'en'] - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'comment': 'Here is a comment', - 'custom_followup_at': '2021-06-12', - 'checkin_attention': True, - 'email': 'foo@bar.com', - 'phone': '+4962219999', - 'locale': 'de', - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "John Doe", - "name_parts": {}, - "street": "", - "state": "", - "zipcode": "", - "city": "Paris", - "country": "FR", - "internal_reference": "", - "vat_id": "", - } - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert order.comment == 'Here is a comment' - assert order.custom_followup_at.isoformat() == '2021-06-12' - assert order.checkin_attention - assert order.email == 'foo@bar.com' - assert order.phone == '+4962219999' - assert order.locale == 'de' - assert order.invoice_address.company == "This is my company name" - assert order.invoice_address.name_cached == "John Doe" - assert order.invoice_address.name_parts == {'_legacy': 'John Doe'} - assert str(order.invoice_address.country) == "FR" - assert not order.invoice_address.vat_id_validated - assert order.invoice_address.city == "Paris" - with scopes_disabled(): - assert order.all_logentries().get(action_type='pretix.event.order.comment') - assert order.all_logentries().get(action_type='pretix.event.order.custom_followup_at') - assert order.all_logentries().get(action_type='pretix.event.order.checkin_attention') - assert order.all_logentries().get(action_type='pretix.event.order.contact.changed') - assert order.all_logentries().get(action_type='pretix.event.order.phone.changed') - assert order.all_logentries().get(action_type='pretix.event.order.locale.changed') - assert order.all_logentries().get(action_type='pretix.event.order.modified') - - -@pytest.mark.django_db -def test_order_update_validated_vat_id(token_client, organizer, event, order): - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "John Doe", - "name_parts": {}, - "street": "", - "state": "", - "zipcode": "", - "city": "Paris", - "country": "FR", - "internal_reference": "", - "vat_id": "FR123", - "vat_id_validated": True - } - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert order.invoice_address.vat_id == "FR123" - assert order.invoice_address.vat_id_validated - - -@pytest.mark.django_db -def test_order_update_invoiceaddress_delete_create(token_client, organizer, event, order): - event.settings.locales = ['de', 'en'] - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': None, - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - with pytest.raises(InvoiceAddress.DoesNotExist): - order.invoice_address - - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'invoice_address': { - "is_business": False, - "company": "This is my company name", - "name": "", - "name_parts": {}, - "street": "", - "state": "", - "zipcode": "", - "city": "Paris", - "country": "Fr", - "internal_reference": "", - "vat_id": "", - } - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert order.invoice_address.company == "This is my company name" - assert str(order.invoice_address.country) == "FR" - assert order.invoice_address.city == "Paris" - - -@pytest.mark.django_db -def test_order_update_email_to_none(token_client, organizer, event, order): - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'email': None, - } - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert order.email is None - - -@pytest.mark.django_db -def test_order_update_locale_to_invalid(token_client, organizer, event, order): - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orders/{}/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={ - 'locale': 'de', - } - ) - assert resp.status_code == 400 - assert resp.data == {'locale': ['"de" is not a supported locale for this event.']} - - -@pytest.mark.django_db -def test_order_create_invoice(token_client, organizer, event, order): - event.settings.invoice_generate = 'True' - - event.settings.invoice_generate_sales_channels = [] - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 400 - - event.settings.invoice_generate_sales_channels = ['web'] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 201 - with scopes_disabled(): - pos = order.positions.first() - assert json.loads(json.dumps(resp.data)) == { - 'order': 'FOO', - 'number': 'DUMMY-00001', - 'is_cancellation': False, - "invoice_from_name": "", - "invoice_from": "", - "invoice_from_zipcode": "", - "invoice_from_city": "", - "invoice_from_country": None, - "invoice_from_tax_id": "", - "invoice_from_vat_id": "", - "invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123", - "invoice_to_company": "Sample company", - "invoice_to_name": "", - "invoice_to_street": "", - "invoice_to_zipcode": "", - "invoice_to_city": "", - "invoice_to_state": "", - "invoice_to_country": "NZ", - "invoice_to_vat_id": "DE123", - "invoice_to_beneficiary": "", - "custom_field": None, - 'date': now().date().isoformat(), - 'refers': None, - 'locale': 'en', - 'introductory_text': '', - 'additional_text': '', - 'payment_provider_text': '', - 'footer_text': '', - 'lines': [ - { - 'position': 1, - 'description': 'Budget Ticket
Attendee: Peter', - 'subevent': None, - 'event_date_from': '2017-12-27T10:00:00Z', - 'event_date_to': None, - 'event_location': None, - 'fee_type': None, - 'fee_internal_type': None, - 'attendee_name': 'Peter', - 'item': pos.item_id, - 'variation': None, - 'gross_value': '23.00', - 'tax_value': '0.00', - 'tax_rate': '0.00', - 'tax_name': '' - }, - { - 'position': 2, - 'description': 'Payment fee', - 'subevent': None, - 'event_date_from': '2017-12-27T10:00:00Z', - 'event_date_to': None, - 'event_location': None, - 'fee_type': "payment", - 'fee_internal_type': None, - 'attendee_name': None, - 'item': None, - 'variation': None, - 'gross_value': '0.25', - 'tax_value': '0.05', - 'tax_rate': '19.00', - 'tax_name': '' - } - ], - 'foreign_currency_display': None, - 'foreign_currency_rate': None, - 'foreign_currency_rate_date': None, - 'internal_reference': '' - } - - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.data == {'detail': 'An invoice for this order already exists.'} - assert resp.status_code == 400 - - event.settings.invoice_generate = 'False' - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/create_invoice/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 400 - assert resp.data == {'detail': 'You cannot generate an invoice for this order.'} - - -@pytest.mark.django_db -def test_order_regenerate_secrets(token_client, organizer, event, order): - s = order.secret - with scopes_disabled(): - ps = order.positions.first().secret - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/regenerate_secrets/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 200 - order.refresh_from_db() - assert s != order.secret - with scopes_disabled(): - assert ps != order.positions.first().secret - - -@pytest.mark.django_db -def test_order_resend_link(token_client, organizer, event, order): - djmail.outbox = [] - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/resend_link/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 204 - assert len(djmail.outbox) == 1 - - order.email = None - order.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/{}/resend_link/'.format( - organizer.slug, event.slug, order.code - ), format='json', data={} - ) - assert resp.status_code == 400 - - -@pytest.mark.django_db -def test_orderposition_price_calculation(token_client, organizer, event, order, item): - with scopes_disabled(): - op = order.positions.first() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('23.00'), - 'gross_formatted': '23.00', - 'name': '', - 'net': Decimal('23.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_item_with_tax(token_client, organizer, event, order, item, taxrule): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23, tax_rule=taxrule) - op = order.positions.first() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('23.00'), - 'gross_formatted': '23.00', - 'name': '', - 'net': Decimal('19.33'), - 'rate': Decimal('19.00'), - 'tax_rule': taxrule.pk, - 'tax': Decimal('3.67') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_item_with_variation(token_client, organizer, event, order): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - var = item2.variations.create(default_price=12, value="XS") - op = order.positions.first() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - 'variation': var.pk - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('12.00'), - 'gross_formatted': '12.00', - 'name': '', - 'net': Decimal('12.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_subevent(token_client, organizer, event, order, subevent): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - op = order.positions.first() - op.subevent = subevent - op.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - 'subevent': subevent.pk - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('23.00'), - 'gross_formatted': '23.00', - 'name': '', - 'net': Decimal('23.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_subevent_with_override(token_client, organizer, event, order, subevent): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) - se2.subeventitem_set.create(item=item2, price=12) - op = order.positions.first() - op.subevent = subevent - op.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - 'subevent': se2.pk - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('12.00'), - 'gross_formatted': '12.00', - 'name': '', - 'net': Decimal('12.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_voucher_matching(token_client, organizer, event, order, subevent, item): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - q = event.quotas.create(name="Quota") - q.items.add(item) - q.items.add(item2) - voucher = event.vouchers.create(price_mode="set", value=15, quota=q) - op = order.positions.first() - op.voucher = voucher - op.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('15.00'), - 'gross_formatted': '15.00', - 'name': '', - 'net': Decimal('15.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_voucher_not_matching(token_client, organizer, event, order, subevent, item): - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=23) - q = event.quotas.create(name="Quota") - q.items.add(item) - voucher = event.vouchers.create(price_mode="set", value=15, quota=q) - op = order.positions.first() - op.voucher = voucher - op.save() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('23.00'), - 'gross_formatted': '23.00', - 'name': '', - 'net': Decimal('23.00'), - 'rate': Decimal('0.00'), - 'tax_rule': None, - 'tax': Decimal('0.00') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_net_price(token_client, organizer, event, order, subevent, item, taxrule): - taxrule.price_includes_tax = False - taxrule.save() - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule) - op = order.positions.first() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('11.90'), - 'gross_formatted': '11.90', - 'name': '', - 'net': Decimal('10.00'), - 'rate': Decimal('19.00'), - 'tax_rule': taxrule.pk, - 'tax': Decimal('1.90') - } - - -@pytest.mark.django_db -def test_orderposition_price_calculation_reverse_charge(token_client, organizer, event, order, subevent, item, taxrule): - taxrule.price_includes_tax = False - taxrule.eu_reverse_charge = True - taxrule.home_country = Country('DE') - taxrule.save() - order.invoice_address.is_business = True - order.invoice_address.vat_id = 'ATU1234567' - order.invoice_address.vat_id_validated = True - order.invoice_address.country = Country('AT') - order.invoice_address.save() - with scopes_disabled(): - item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule) - op = order.positions.first() - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk), - data={ - 'item': item2.pk, - } - ) - assert resp.status_code == 200 - assert resp.data == { - 'gross': Decimal('10.00'), - 'gross_formatted': '10.00', - 'name': '', - 'net': Decimal('10.00'), - 'rate': Decimal('0.00'), - 'tax_rule': taxrule.pk, - 'tax': Decimal('0.00') - } - - @pytest.mark.django_db def test_revoked_secret_list(token_client, organizer, event): r = event.revoked_secrets.create(secret="abcd") @@ -4838,276 +1654,3 @@ def test_revoked_secret_list(token_client, organizer, event): )) assert resp.status_code == 200 assert [res] == resp.data['results'] - - -@pytest.mark.django_db -def test_position_update_ignore_fields(token_client, organizer, event, order): - with scopes_disabled(): - op = order.positions.first() - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data={ - 'price': '99.99' - } - ) - assert resp.status_code == 200 - op.refresh_from_db() - assert op.price == Decimal('23.00') - - -@pytest.mark.django_db -def test_position_update_only_partial(token_client, organizer, event, order): - with scopes_disabled(): - op = order.positions.first() - resp = token_client.put( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data={ - 'price': '99.99' - } - ) - assert resp.status_code == 405 - - -@pytest.mark.django_db -def test_position_update(token_client, organizer, event, order, question): - with scopes_disabled(): - op = order.positions.first() - question.type = Question.TYPE_CHOICE_MULTIPLE - question.save() - opt = question.options.create(answer="L") - payload = { - 'company': 'VILE', - 'attendee_name_parts': { - 'full_name': 'Max Mustermann' - }, - 'street': 'Sesame Street 21', - 'zipcode': '99999', - 'city': 'Springfield', - 'country': 'US', - 'state': 'CA', - 'attendee_email': 'foo@example.org', - 'answers': [ - { - 'question': question.pk, - 'answer': 'ignored', - 'options': [opt.pk] - } - ] - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - assert resp.data['answers'] == [ - { - 'question': question.pk, - 'question_identifier': question.identifier, - 'answer': 'L', - 'options': [opt.pk], - 'option_identifiers': [opt.identifier], - } - ] - op.refresh_from_db() - assert op.company == 'VILE' - assert op.attendee_name_cached == 'Max Mustermann' - assert op.attendee_name_parts == { - '_scheme': 'full', - 'full_name': 'Max Mustermann' - } - with scopes_disabled(): - assert op.answers.get().answer == 'L' - assert op.street == 'Sesame Street 21' - assert op.zipcode == '99999' - assert op.city == 'Springfield' - assert str(op.country) == 'US' - assert op.state == 'CA' - assert op.attendee_email == 'foo@example.org' - le = order.all_logentries().last() - assert le.action_type == 'pretix.event.order.modified' - assert le.parsed_data == { - 'data': [ - { - 'position': op.pk, - 'company': 'VILE', - 'attendee_name_parts': { - '_scheme': 'full', - 'full_name': 'Max Mustermann' - }, - 'street': 'Sesame Street 21', - 'zipcode': '99999', - 'city': 'Springfield', - 'country': 'US', - 'state': 'CA', - 'attendee_email': 'foo@example.org', - f'question_{question.pk}': 'L' - } - ] - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - with scopes_disabled(): - assert order.all_logentries().last().pk == le.pk - - -@pytest.mark.django_db -def test_position_update_legacy_name(token_client, organizer, event, order): - with scopes_disabled(): - op = order.positions.first() - payload = { - 'attendee_name': 'Max Mustermann', - 'attendee_name_parts': { - '_legacy': 'maria' - }, - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 400 - payload = { - 'attendee_name': 'Max Mustermann', - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - op.refresh_from_db() - assert op.attendee_name_cached == 'Max Mustermann' - assert op.attendee_name_parts == { - '_legacy': 'Max Mustermann' - } - with scopes_disabled(): - assert op.answers.count() == 1 # answer does not get deleted - - -@pytest.mark.django_db -def test_position_update_state_validation(token_client, organizer, event, order): - with scopes_disabled(): - op = order.positions.first() - payload = { - 'country': 'DE', - 'state': 'BW' - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 400 - - -@pytest.mark.django_db -def test_position_update_question_handling(token_client, organizer, event, order, question): - with scopes_disabled(): - op = order.positions.first() - payload = { - 'answers': [ - { - 'question': question.pk, - 'answer': 'FOOBAR', - }, - { - 'question': question.pk, - 'answer': 'FOOBAR', - }, - ] - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 400 - payload = { - 'answers': [ - { - 'question': question.pk, - 'answer': 'FOOBAR', - }, - ] - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - with scopes_disabled(): - assert op.answers.count() == 1 - payload = { - 'answers': [ - ] - } - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - with scopes_disabled(): - assert op.answers.count() == 0 - - r = token_client.post( - '/api/v1/upload', - data={ - 'media_type': 'image/png', - 'file': ContentFile('file.png', 'invalid png content') - }, - format='upload', - HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', - ) - assert r.status_code == 201 - file_id_png = r.data['id'] - - payload = { - 'answers': [ - { - "question": question.id, - "answer": file_id_png - } - ] - } - question.type = Question.TYPE_FILE - question.save() - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - with scopes_disabled(): - answ = op.answers.get() - assert answ.file - assert answ.answer.startswith("file://") - - payload = { - 'answers': [ - { - "question": question.id, - "answer": "file:keep" - } - ] - } - question.type = Question.TYPE_FILE - question.save() - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - ), format='json', data=payload - ) - assert resp.status_code == 200 - with scopes_disabled(): - answ = op.answers.get() - assert answ.file - assert answ.answer.startswith("file://")