forked from CGM_Public/pretix_original
* [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')
|
||||
|
||||
Reference in New Issue
Block a user