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
This commit is contained in:
Raphael Michel
2018-05-16 12:14:31 +02:00
committed by GitHub
parent 359a5d01e6
commit 35e8dcf2bc
9 changed files with 1510 additions and 43 deletions

View File

@@ -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

View File

@@ -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')