From 35e8dcf2bcf5862f376b5cbdffb6a0582d9cab67 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 16 May 2018 12:14:31 +0200 Subject: [PATCH] Fix #599 -- Add API to create orders (#911) * [WIP] Fix #599 -- Add API to create orders * Add more validation logic * Add docs and some validation * Fix test on MySQl * Validation is fun, let's do more of it! * Fix live_issues --- doc/api/resources/orders.rst | 176 +++++- doc/spelling_wordlist.txt | 1 + src/pretix/api/serializers/order.py | 291 ++++++++- src/pretix/api/views/order.py | 45 +- src/pretix/base/models/event.py | 2 +- src/pretix/base/models/orders.py | 33 +- src/pretix/base/payment.py | 35 +- src/pretix/base/services/orders.py | 37 +- src/tests/api/test_orders.py | 933 +++++++++++++++++++++++++++- 9 files changed, 1510 insertions(+), 43 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index f95eac7fdf..99ddf8a0b6 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -100,6 +100,7 @@ last_modified datetime Last modificati .. versionchanged:: 1.16 The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added. + An endpoint for order creation has been added. .. _order-position-resource: @@ -112,7 +113,7 @@ Order position resource Field Type Description ===================================== ========================== ======================================================= id integer Internal ID of the order position -code string Order code of the order the position belongs to +order string Order code of the order the position belongs to positionid integer Number of the position within the order item integer ID of the purchased item variation integer ID of the purchased variation (or ``null``) @@ -425,6 +426,179 @@ Order endpoints :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/ + + Creates a new order. + + .. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice. + + .. warning:: + + This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend, + it's rather intended to import attendees from external sources etc. + There is a lot that it does not or can not do, and you will need to be careful using it. + It allows to bypass many of the restrictions imposed when creating an order through the + regular shop. + + Specifically, this endpoint currently + + * does not validate if products are only to be sold in a specific time frame + + * does not validate if the event's ticket sales are already over or haven't started + + * does not validate the number of items per order or the number of times an item can be included in an order + + * does not validate any requirements related to add-on products + + * does not check or calculate prices but believes any prices you send + + * does not support the redemption of vouchers + + * does not prevent you from buying items that can only be bought with a voucher + + * does not calculate fees + + * does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping + module + + * does not send order confirmations via email + + * does not support reverse charge taxation + + * does not support file upload questions + + You can supply the following fields of the resource: + + * ``code`` (optional) + * ``status`` (optional) – Defaults to pending for non-free orders and paid for free orders. You can only set this to + ``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be + sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call + the ``mark_paid`` API method. + * ``email`` + * ``locale`` + * ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing + payment provider. You should use ``"free"`` for free orders. + * ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``payment_info`` + value of the order. How this value is handled is up to the payment provider and you should only use this if you + know the specific payment provider in detail. Please keep in mind that the payment provider will not be called + to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created), + this is just informative in case you *handled the payment already*. + * ``comment`` (optional) + * ``checkin_attention`` (optional) + * ``invoice_address`` (optional) + + * ``company`` + * ``is_business`` + * ``name`` + * ``street`` + * ``zipcode`` + * ``city`` + * ``country`` + * ``internal_reference`` + * ``vat_id`` + + * ``positions`` + + * ``positionid`` (optional, see below) + * ``item`` + * ``variation`` + * ``price`` + * ``attendee_name`` + * ``attendee_email`` + * ``secret`` (optional) + * ``addon_to`` (optional, see below) + * ``subevent`` + * ``answers`` + + * ``question`` + * ``answer`` + * ``options`` + + * ``fees`` + + * ``fee_type`` + * ``value`` + * ``description`` + * ``internal_type`` + * ``tax_rule`` + + If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually + to incrementing integers starting with ``1``. Then, you can reference one of these + IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come + immediately after the position itself. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "email": "dummy@example.org", + "locale": "en", + "fees": [ + { + "fee_type": "payment", + "value": "0.25", + "description": "", + "internal_type": "", + "tax_rule": 2 + } + ], + "payment_provider": "banktransfer", + "invoice_address": { + "is_business": False, + "company": "Sample company", + "name": "John Doe", + "street": "Sesam Street 12", + "zipcode": "12345", + "city": "Sample City", + "country": "UK", + "internal_reference": "", + "vat_id": "" + }, + "positions": [ + { + "positionid": 1, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "addon_to": None, + "answers": [ + { + "question": 1, + "answer": "23", + "options": [] + } + ], + "subevent": None + } + ], + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + (Full order resource, see above.) + + :param organizer: The ``slug`` field of the organizer of the event to create an item for + :param event: The ``slug`` field of the event to create an item for + :statuscode 201: no error + :statuscode 400: The item could not be created due to invalid submitted data or lack of quota. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this + order. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/ Marks a pending or expired order as successfully paid. diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index e49ee6c92f..ca7b6eade4 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -38,6 +38,7 @@ gunicorn hardcoded hostname idempotency +incrementing inofficial invalidations iterable diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 9d11b3acc6..0169a73089 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,16 +1,25 @@ +import json +from collections import Counter +from decimal import Decimal + +from django_countries.fields import Country from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.reverse import reverse from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, - QuestionAnswer, + Question, QuestionAnswer, Quota, ) from pretix.base.models.orders import OrderFee from pretix.base.signals import register_ticket_outputs class CompatibleCountryField(serializers.Field): + def to_internal_value(self, data): + return {self.field_name: Country(data)} + def to_representation(self, instance: InvoiceAddress): if instance.country: return str(instance.country) @@ -25,6 +34,13 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): model = InvoiceAddress fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id', 'vat_id_validated', 'internal_reference') + read_only_fields = ('last_modified', 'vat_id_validated') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for v in self.fields.values(): + v.required = False + v.allow_blank = True class AnswerQuestionIdentifierField(serializers.Field): @@ -134,6 +150,279 @@ class OrderSerializer(I18nAwareModelSerializer): 'checkin_attention', 'last_modified') +class AnswerCreateSerializer(I18nAwareModelSerializer): + + class Meta: + model = QuestionAnswer + fields = ('question', 'answer', 'options') + + def validate_question(self, q): + if q.event != self.context['event']: + raise ValidationError( + 'The specified question does not belong to this event.' + ) + return q + + def validate(self, data): + if data.get('question').type == Question.TYPE_FILE: + raise ValidationError( + 'File uploads are currently not supported via the API.' + ) + elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE): + if not data.get('options'): + raise ValidationError( + 'You need to specify options if the question is of a choice type.' + ) + if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1: + raise ValidationError( + 'You can specify at most one option for this question.' + ) + data['answer'] = ", ".join([str(o) for o in data.get('options')]) + + else: + if data.get('options'): + raise ValidationError( + 'You should not specify options if the question is not of a choice type.' + ) + + if data.get('question').type == Question.TYPE_BOOLEAN: + if data.get('answer') in ['true', 'True', '1', 'TRUE']: + data['answer'] = 'True' + elif data.get('answer') in ['false', 'False', '0', 'FALSE']: + data['answer'] = 'False' + else: + raise ValidationError( + 'Please specify "true" or "false" for boolean questions.' + ) + elif data.get('question').type == Question.TYPE_NUMBER: + serializers.DecimalField( + max_digits=50, + decimal_places=25 + ).to_internal_value(data.get('answer')) + elif data.get('question').type == Question.TYPE_DATE: + data['answer'] = serializers.DateField().to_internal_value(data.get('answer')) + elif data.get('question').type == Question.TYPE_TIME: + data['answer'] = serializers.TimeField().to_internal_value(data.get('answer')) + elif data.get('question').type == Question.TYPE_DATETIME: + data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer')) + return data + + +class OrderFeeCreateSerializer(I18nAwareModelSerializer): + class Meta: + model = OrderFee + fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule') + + def validate_tax_rule(self, tr): + if tr and tr.event != self.context['event']: + raise ValidationError( + 'The specified tax rate does not belong to this event.' + ) + return tr + + +class OrderPositionCreateSerializer(I18nAwareModelSerializer): + answers = AnswerCreateSerializer(many=True, required=False) + addon_to = serializers.IntegerField(required=False, allow_null=True) + secret = serializers.CharField(required=False) + + class Meta: + model = OrderPosition + fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email', + 'secret', 'addon_to', 'subevent', 'answers') + + def validate_secret(self, secret): + if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists(): + raise ValidationError( + 'You cannot assign a position secret that already exists.' + ) + return secret + + def validate_item(self, item): + if item.event != self.context['event']: + raise ValidationError( + 'The specified item does not belong to this event.' + ) + if not item.active: + raise ValidationError( + 'The specified item is not active.' + ) + 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): + if data.get('item'): + if data.get('item').has_variations: + if not data.get('variation'): + raise ValidationError('You should specify a variation for this item.') + else: + if data.get('variation').item != data.get('item'): + raise ValidationError( + 'The specified variation does not belong to the specified item.' + ) + elif data.get('variation'): + raise ValidationError( + 'You cannot specify a variation for this item.' + ) + return data + + +class CompatibleJSONField(serializers.JSONField): + def to_internal_value(self, data): + try: + return json.dumps(data) + except (TypeError, ValueError): + self.fail('invalid') + + def to_representation(self, value): + if value: + return json.load(value) + return value + + +class OrderCreateSerializer(I18nAwareModelSerializer): + invoice_address = InvoiceAddressSerializer(required=False) + positions = OrderPositionCreateSerializer(many=True, required=False) + fees = OrderFeeCreateSerializer(many=True, required=False) + status = serializers.ChoiceField(choices=( + ('n', Order.STATUS_PENDING), + ('p', Order.STATUS_PAID), + ), default='n', required=False) + code = serializers.CharField( + required=False, + max_length=16, + min_length=5 + ) + comment = serializers.CharField(required=False, allow_blank=True) + payment_provider = serializers.CharField(required=True) + payment_info = CompatibleJSONField(required=False) + + class Meta: + model = Order + fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', + 'invoice_address', 'positions', 'checkin_attention', 'payment_info') + + def validate_payment_provider(self, pp): + if pp not in self.context['event'].get_payment_providers(): + raise ValidationError('The given payment provider is not known.') + return pp + + def validate_code(self, code): + if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists(): + raise ValidationError( + 'This order code is already in use.' + ) + if any(c not in 'ABCDEFGHJKLMNPQRSTUVWXYZ1234567890' for c in code): + raise ValidationError( + 'This order code contains invalid characters.' + ) + return code + + def validate_positions(self, data): + if not data: + raise ValidationError( + 'An order cannot be empty.' + ) + if any([p.get('positionid') for p in data]): + if not all([p.get('positionid') for p in data]): + raise ValidationError( + 'If you set position IDs manually, you need to do so for all positions.' + ) + + last_non_add_on = None + last_posid = 0 + + for p in data: + if p['positionid'] != last_posid + 1: + raise ValidationError("Position IDs need to be consecutive.") + if p.get('addon_to') and p['addon_to'] != last_non_add_on: + raise ValidationError("If you set addon_to, you need to make sure that the referenced " + "position ID exists and is transmitted directly before its add-ons.") + + if not p.get('addon_to'): + last_non_add_on = p['positionid'] + last_posid = p['positionid'] + + elif any([p.get('addon_to') for p in data]): + raise ValidationError("If you set addon_to, you need to specify position IDs manually.") + return data + + def create(self, validated_data): + fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] + positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] + if 'invoice_address' in validated_data: + ia = InvoiceAddress(**validated_data.pop('invoice_address')) + else: + ia = None + + with self.context['event'].lock(): + quotadiff = Counter() + for pos_data in positions_data: + new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent')) + if pos_data.get('variation') + else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))) + quotadiff.update(new_quotas) + + for quota, diff in quotadiff.items(): + avail = quota.availability() + if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): + raise ValidationError( + 'There is not enough quota available on quota "{}" to perform the operation.'.format( + quota.name + ) + ) + + order = Order(event=self.context['event'], **validated_data) + order.set_expires(subevents=[p['subevent'] for p in positions_data]) + order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00')) + if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID: + order.payment_provider = 'free' + order.status = Order.STATUS_PAID + elif order.payment_provider == "free" and order.total != Decimal('0.00'): + raise ValidationError('You cannot use the "free" payment provider for non-free orders.') + order.save() + if ia: + ia.order = order + ia.save() + pos_map = {} + for pos_data in positions_data: + answers_data = pos_data.pop('answers') + addon_to = pos_data.pop('addon_to') + pos = OrderPosition(**pos_data) + pos.order = order + pos._calculate_tax() + if addon_to: + pos.addon_to = pos_map[addon_to] + pos.save() + pos_map[pos.positionid] = pos + for answ_data in answers_data: + options = answ_data.pop('options') + answ = pos.answers.create(**answ_data) + answ.options.add(*options) + for fee_data in fees_data: + f = OrderFee(**fee_data) + f.order = order + f._calculate_tax() + f.save() + + return order + + class InlineInvoiceLineSerializer(I18nAwareModelSerializer): class Meta: model = InvoiceLine diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 09466371c6..193fec081a 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -2,6 +2,7 @@ import datetime import django_filters import pytz +from django.db import transaction from django.db.models import Q from django.db.models.functions import Concat from django.http import FileResponse @@ -13,15 +14,18 @@ from rest_framework.exceptions import ( APIException, NotFound, PermissionDenied, ValidationError, ) from rest_framework.filters import OrderingFilter +from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response from pretix.api.serializers.order import ( - InvoiceSerializer, OrderPositionSerializer, OrderSerializer, + InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer, + OrderSerializer, ) from pretix.base.models import Invoice, Order, OrderPosition, Quota from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.invoices import ( - generate_cancellation, generate_invoice, invoice_pdf, regenerate_invoice, + generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, + regenerate_invoice, ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( @@ -31,7 +35,7 @@ from pretix.base.services.orders import ( from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, ) -from pretix.base.signals import register_ticket_outputs +from pretix.base.signals import order_placed, register_ticket_outputs class OrderFilter(FilterSet): @@ -45,7 +49,7 @@ class OrderFilter(FilterSet): fields = ['code', 'status', 'email', 'locale'] -class OrderViewSet(viewsets.ReadOnlyModelViewSet): +class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer queryset = Order.objects.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -56,6 +60,11 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): permission = 'can_view_orders' write_permission = 'can_change_orders' + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + def get_queryset(self): return self.request.event.orders.prefetch_related( 'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options', @@ -226,6 +235,34 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): status=status.HTTP_400_BAD_REQUEST ) + def create(self, request, *args, **kwargs): + serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + with transaction.atomic(): + self.perform_create(serializer) + order = serializer.instance + serializer = OrderSerializer(order, context=serializer.context) + + order.log_action( + 'pretix.event.order.placed', + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + ) + order_placed.send(self.request.event, order=order) + + gen_invoice = invoice_qualified(order) and ( + (order.event.settings.get('invoice_generate') == 'True') or + (order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID) + ) and not order.invoices.last() + if gen_invoice: + generate_invoice(order, trigger_pdf=True) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + serializer.save() + class OrderPositionFilter(FilterSet): order = django_filters.CharFilter(name='order', lookup_expr='code__iexact') diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 641a9a89e9..2ae2eb1b9e 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -545,7 +545,7 @@ class Event(EventMixin, LoggedModel): def has_payment_provider(self): result = False for provider in self.get_payment_providers().values(): - if provider.is_enabled and provider.identifier != 'free': + if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'): result = True break return result diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 8d3edecd02..6ed7943524 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2,7 +2,7 @@ import copy import json import os import string -from datetime import datetime, time +from datetime import datetime, time, timedelta from decimal import Decimal from typing import Any, Dict, List, Union @@ -218,11 +218,42 @@ class Order(LoggedModel): self.assign_code() if not self.datetime: self.datetime = now() + if not self.expires: + self.set_expires() super().save(**kwargs) def touch(self): self.save(update_fields=['last_modified']) + def set_expires(self, now_dt=None, subevents=None): + now_dt = now_dt or now() + tz = pytz.timezone(self.event.settings.timezone) + exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int)) + exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) + if self.event.settings.get('payment_term_weekdays'): + if exp_by_date.weekday() == 5: + exp_by_date += timedelta(days=2) + elif exp_by_date.weekday() == 6: + exp_by_date += timedelta(days=1) + + self.expires = exp_by_date + + term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if term_last: + if self.event.has_subevents and subevents: + term_last = min([ + term_last.datetime(se).date() + for se in subevents + ]) + else: + term_last = term_last.datetime(self.event).date() + term_last = make_aware(datetime.combine( + term_last, + time(hour=23, minute=59, second=59) + ), tz) + if term_last < self.expires: + self.expires = term_last + @cached_property def tax_total(self): return (self.positions.aggregate(s=Sum('tax_value'))['s'] or 0) + (self.fees.aggregate(s=Sum('tax_value'))['s'] or 0) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 265adb5f6d..c611f07e51 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -658,6 +658,39 @@ class FreeOrderProvider(BasePaymentProvider): return False +class BoxOfficeProvider(BasePaymentProvider): + is_implicit = True + is_enabled = True + identifier = "boxoffice" + verbose_name = _("Box office") + + def payment_perform(self, request: HttpRequest, order: Order): + from pretix.base.services.orders import mark_order_paid + try: + mark_order_paid(order, 'boxoffice', send_mail=False) + except Quota.QuotaExceededException as e: + raise PaymentException(str(e)) + + @property + def settings_form_fields(self) -> dict: + return {} + + def order_control_refund_render(self, order: Order) -> str: + return '' + + def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: + from pretix.base.services.orders import mark_order_refunded + + mark_order_refunded(order, user=request.user) + messages.success(request, _('The order has been marked as refunded.')) + + def is_allowed(self, request: HttpRequest) -> bool: + return False + + def order_change_allowed(self, order: Order) -> bool: + return False + + @receiver(register_payment_providers, dispatch_uid="payment_free") def register_payment_provider(sender, **kwargs): - return FreeOrderProvider + return [FreeOrderProvider, BoxOfficeProvider] diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index f275dfe56e..66cb63fb1a 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -13,7 +13,7 @@ from django.db import transaction from django.db.models import F, Max, Q, Sum from django.dispatch import receiver from django.utils.formats import date_format -from django.utils.timezone import make_aware, now +from django.utils.timezone import now from django.utils.translation import ugettext as _ from pretix.base.i18n import ( @@ -31,7 +31,6 @@ from pretix.base.models.orders import ( from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TaxedPrice from pretix.base.payment import BasePaymentProvider -from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.async import ProfiledTask from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, @@ -448,50 +447,22 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, meta_info: dict=None): - from datetime import time - fees = _get_fees(positions, payment_provider, address, meta_info, event) total = sum([c.price for c in positions]) + sum([c.value for c in fees]) - tz = pytz.timezone(event.settings.timezone) - exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int)) - exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) - if event.settings.get('payment_term_weekdays'): - if exp_by_date.weekday() == 5: - exp_by_date += timedelta(days=2) - elif exp_by_date.weekday() == 6: - exp_by_date += timedelta(days=1) - - expires = exp_by_date - - term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if term_last: - if event.has_subevents: - term_last = min([ - term_last.datetime(se).date() - for se in event.subevents.filter(id__in=[p.subevent_id for p in positions]) - ]) - else: - term_last = term_last.datetime(event).date() - term_last = make_aware(datetime.combine( - term_last, - time(hour=23, minute=59, second=59) - ), tz) - if term_last < expires: - expires = term_last - with transaction.atomic(): - order = Order.objects.create( + order = Order( status=Order.STATUS_PENDING, event=event, email=email, datetime=now_dt, - expires=expires, locale=locale, total=total, payment_provider=payment_provider.identifier, meta_info=json.dumps(meta_info or {}), ) + order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) + order.save() if address: if address.order is not None: diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 5b79febc3f..03a410e1a4 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -1,5 +1,6 @@ import copy import datetime +import json from decimal import Decimal from unittest import mock @@ -9,7 +10,7 @@ from django.utils.timezone import now from django_countries.fields import Country from pytz import UTC -from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.models import InvoiceAddress, Order, OrderPosition, Question from pretix.base.models.orders import OrderFee from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, @@ -21,6 +22,11 @@ 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')) @@ -34,6 +40,13 @@ def question(event, item): 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) @@ -752,3 +765,921 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order, order.refresh_from_db() assert order.status == Order.STATUS_PENDING assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" + + +ORDER_CREATE_PAYLOAD = { + "email": "dummy@dummy.test", + "locale": "en", + "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": "Fo", + "street": "Bar", + "zipcode": "", + "city": "Sample City", + "country": "NZ", + "internal_reference": "", + "vat_id": "" + }, + "positions": [ + { + "positionid": 1, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "addon_to": None, + "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 + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + o = Order.objects.get(code=resp.data['code']) + assert o.email == "dummy@dummy.test" + assert o.locale == "en" + assert o.total == Decimal('23.25') + assert o.status == Order.STATUS_PENDING + assert o.payment_provider == "banktransfer" + 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 o.positions.count() == 1 + pos = o.positions.first() + assert pos.item == item + assert pos.price == Decimal("23.00") + answ = pos.answers.first() + assert answ.question == question + assert answ.answer == "S" + + +@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 + o = Order.objects.get(code=resp.data['code']) + with pytest.raises(InvoiceAddress.DoesNotExist): + o.invoice_address + + +@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 + 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 + o = Order.objects.get(code=resp.data['code']) + assert not o.email + + +@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 + o = Order.objects.get(code=resp.data['code']) + assert not o.payment_info == "{}" + + 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 + o = Order.objects.get(code=resp.data['code']) + assert json.loads(o.payment_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 + 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 + 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 + 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" + 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_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.']}]} + + var2 = item2.variations.create(value="A") + + 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': [{'non_field_errors': ['You cannot specify a variation for this item.']}]} + + 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 == 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': [{'non_field_errors': ['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': [{'non_field_errors': ['You should specify a variation for this item.']}]} + + +@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": "Peter", + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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 + 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": "Peter", + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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': ['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": "Peter", + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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': ['If you set addon_to, you need to specify position IDs manually.']} + + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "answers": [], + "subevent": None + }, + { + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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': ['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": "Peter", + "attendee_email": None, + "answers": [], + "subevent": None + }, + { + "positionid": 3, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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': ['Position IDs need to be consecutive.']} + + +@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.']}]}]} + + question.options.create(answer="L") + 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.']}]}]} + + 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 == 400 + assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]}]} + + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + 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 == ['There is not enough quota available on quota "Budget Quota" to perform the operation.'] + + quota.size = 1 + quota.save() + res['positions'] = [ + { + "positionid": 1, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "addon_to": None, + "answers": [], + "subevent": None + }, + { + "positionid": 2, + "item": item.pk, + "variation": None, + "price": "23.00", + "attendee_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 == 400 + assert resp.data == ['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 + o = Order.objects.get(code=resp.data['code']) + assert o.total == Decimal('0.00') + assert o.status == Order.STATUS_PAID + assert o.payment_provider == "free" + + +@pytest.mark.django_db +def test_order_create_require_payment_provider(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + del res['payment_provider'] + 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': ['This field is required.']} + + +@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['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 + o = Order.objects.get(code=resp.data['code']) + assert o.invoices.count() == 1