mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
* [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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user