API: Allow to change orders (#2552)

This commit is contained in:
Raphael Michel
2022-03-30 17:36:10 +02:00
committed by GitHub
parent 76475039b5
commit 69375f4092
10 changed files with 5678 additions and 3568 deletions

View File

@@ -424,88 +424,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data', None)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = [
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email',
]
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
raise TypeError("this serializer is readonly")
class RequireAttentionField(serializers.Field):
@@ -593,7 +512,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
class PaymentURLField(serializers.URLField):
@@ -1361,14 +1280,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if not simulate:
if simulate:
f.id = 0
else:
f.save()
else:
f = OrderFee(**fee_data)
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if not simulate:
if simulate:
f.id = 0
else:
f.save()
order.total += sum([f.value for f in fees])

View File

@@ -0,0 +1,424 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import os
import pycountry
from django.core.files import File
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__)
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['seat'].queryset = self.context['event'].seats.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event'])
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if data.get('addon_to'):
try:
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
except OrderPosition.DoesNotExist:
raise ValidationError({
'addon_to': ['addon_to refers to an unknown position ID for this order.']
})
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
ocm.add_position(
item=validated_data['item'],
variation=validated_data.get('variation'),
price=validated_data.get('price'),
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta:
model = OrderPosition
fields = (
'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email', 'answers',
)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in self.fields:
setattr(instance, attr, value)
instance.save(update_fields=list(validated_data.keys()))
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class OrderPositionChangeSerializer(serializers.ModelSerializer):
seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False)
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if kwargs.get('partial'):
for k, v in self.fields.items():
self.fields[k].required = False
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data, instance=None):
instance = instance or self.instance
if instance is None:
return data # needs to be done later
if data.get('item', instance.item):
if data.get('item', instance.item).has_variations:
if not data.get('variation', instance.variation):
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation', instance.variation).item != data.get('item', instance.item):
raise ValidationError(
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation', instance.variation):
raise ValidationError(
{'variation': ['You cannot specify a variation for this item.']}
)
return data
def update(self, instance, validated_data):
ocm = self.context['ocm']
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
subevent = validated_data.get('subevent', instance.subevent)
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
change_item = None
if item != instance.item or variation != instance.variation:
change_item = (item, variation)
change_subevent = None
if self.context['event'].has_subevents and subevent != instance.subevent:
change_subevent = (subevent,)
try:
if change_item is not None and change_subevent is not None:
ocm.change_item_and_subevent(instance, *change_item, *change_subevent)
elif change_item is not None:
ocm.change_item(instance, *change_item)
elif change_subevent is not None:
ocm.change_subevent(instance, *change_subevent)
if seat != current_seat or change_subevent:
ocm.change_seat(instance, seat['seat_guid'] if seat else None)
if price != instance.price:
ocm.change_price(instance, price)
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def validate_position(self, value):
self.fields['body'].instance = value # hack around DRFs validation order
return value
def validate(self, data):
OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position'])
return data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True)
class SelectPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
class OrderFeeChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderFee
fields = (
'value',
)
def update(self, instance, validated_data):
ocm = self.context['ocm']
value = validated_data.get('value', instance.value)
try:
if value != instance.value:
ocm.change_fee(instance, value)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['fee'].queryset = self.context['order'].fees.all()
self.fields['body'] = OrderFeeChangeSerializer(context=self.context)
class SelectFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['fee'].queryset = self.context['order'].fees.all()
class OrderChangeOperationSerializer(serializers.Serializer):
send_email = serializers.BooleanField(default=False, required=False)
reissue_invoice = serializers.BooleanField(default=True, required=False)
recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[
('keep_net', 'keep_net'),
('keep_gross', 'keep_gross'),
])
def __init__(self, *args, **kwargs):
super().__init__(self, *args, **kwargs)
self.fields['patch_positions'] = PatchPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_fees'] = SelectFeeSerializer(
many=True, required=False, context=self.context
)
def validate(self, data):
seen_positions = set()
for d in data.get('patch_positions', []):
print(d, seen_positions)
if d['position'] in seen_positions:
raise ValidationError({'patch_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('cancel_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'cancel_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('split_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'split_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_fees = set()
for d in data.get('patch_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'patch_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
seen_fees = set()
for d in data.get('cancel_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'cancel_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
return data

View File

@@ -36,7 +36,7 @@ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from PIL import Image
from rest_framework import mixins, serializers, status, viewsets
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
@@ -53,6 +53,12 @@ from pretix.api.serializers.order import (
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
@@ -782,6 +788,79 @@ class OrderViewSet(viewsets.ModelViewSet):
with transaction.atomic():
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
data=request.data,
)
serializer.is_valid(raise_exception=True)
try:
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=serializer.validated_data.get('send_email', False),
reissue_invoice=serializer.validated_data.get('reissue_invoice', True),
)
canceled_positions = set()
for r in serializer.validated_data.get('cancel_positions', []):
ocm.cancel(r['position'])
canceled_positions.add(r['position'])
for r in serializer.validated_data.get('patch_positions', []):
if r['position'] in canceled_positions:
continue
pos_serializer = OrderPositionChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
partial=True,
)
pos_serializer.update(r['position'], r['body'])
for r in serializer.validated_data.get('split_positions', []):
if r['position'] in canceled_positions:
continue
ocm.split(r['position'])
for r in serializer.validated_data.get('create_positions', []):
pos_serializer = OrderPositionCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
canceled_fees = set()
for r in serializer.validated_data.get('cancel_fees', []):
ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees:
continue
pos_serializer = OrderFeeChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.update(r['fee'], r['body'])
if serializer.validated_data.get('recalculate_taxes') == 'keep_net':
ocm.recalculate_taxes(keep='net')
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
order.refresh_from_db()
serializer = OrderSerializer(
instance=order,
context=self.get_serializer_context(),
)
return Response(serializer.data)
with scopes_disabled():
class OrderPositionFilter(FilterSet):
@@ -823,7 +902,7 @@ with scopes_disabled():
}
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -1060,6 +1139,25 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.regenerate_secret(instance)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
@@ -1075,18 +1173,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
def create(self, request, *args, **kwargs):
with transaction.atomic():
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionCreateForExistingOrderSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
order = serializer.validated_data['order']
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
serializer.context['ocm'] = ocm
serializer.save()
# Fields that can be easily patched after the position was added
old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=serializer.instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
@@ -1109,9 +1222,77 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
]
}
)
tickets.invalidate_cache.apply_async(
kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(
OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data,
status=status.HTTP_201_CREATED,
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
with transaction.atomic():
instance = self.get_object()
ocm = OrderChangeManager(
order=instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
# Field that need to go through OrderChangeManager
serializer = OrderPositionChangeSerializer(
instance=instance,
context={'ocm': ocm, **self.get_serializer_context()},
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
# Fields that can be easily patched
old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
if old_data != new_data:
log_data = self.request.data
if 'answers' in log_data:
for a in new_data['answers']:
log_data[f'question_{a["question"]}'] = a["answer"]
log_data.pop('answers', None)
serializer.instance.order.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'data': [
dict(
position=serializer.instance.pk,
**log_data
)
]
}
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data)
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):

View File

@@ -1467,7 +1467,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
if isinstance(seat, str):
if not seat:
@@ -1492,6 +1492,8 @@ class OrderChangeManager:
if price is None:
raise OrderError(self.error_messages['product_invalid'])
if item.variations.exists() and not variation:
raise OrderError(self.error_messages['product_without_variation'])
if not addon_to and item.category and item.category.is_addon:
raise OrderError(self.error_messages['addon_to_required'])
if addon_to:

View File

@@ -0,0 +1,311 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
from decimal import Decimal
from unittest import mock
import freezegun
import pytest
from django_countries.fields import Country
from django_scopes import scopes_disabled
from pytz import UTC
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice,
)
@pytest.fixture
def item(event):
return event.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def item2(event2):
return event2.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def taxrule(event):
return event.tax_rules.create(rate=Decimal('19.00'))
@pytest.fixture
def question(event, item):
q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC")
q.items.add(item)
q.options.create(answer="XL", identifier="LVETRWVU")
return q
@pytest.fixture
def question2(event2, item2):
q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC")
q.items.add(item2)
return q
@pytest.fixture
def quota(event, item):
q = event.quotas.create(name="Budget Quota", size=200)
q.items.add(item)
return q
@pytest.fixture
def order(event, item, taxrule, question):
testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC)
event.plugins += ",pretix.plugins.stripe"
event.save()
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
total=23, locale='en'
)
p1 = o.payments.create(
provider='stripe',
state='refunded',
amount=Decimal('23.00'),
payment_date=testtime,
)
o.refunds.create(
provider='stripe',
state='done',
source='admin',
amount=Decimal('23.00'),
execution_date=testtime,
payment=p1,
)
o.payments.create(
provider='banktransfer',
state='pending',
amount=Decimal('23.00'),
)
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
tax_value=Decimal('0.05'), tax_rule=taxrule)
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
tax_value=Decimal('0.05'), tax_rule=taxrule, canceled=True)
InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'),
vat_id="DE123", vat_id_validated=True)
op = OrderPosition.objects.create(
order=o,
item=item,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
pseudonymization_id="ABCDEFGHKL",
positionid=1,
)
OrderPosition.objects.create(
order=o,
item=item,
variation=None,
price=Decimal("23"),
attendee_name_parts={"full_name": "Peter", "_scheme": "full"},
secret="YBiYJrmF5ufiTLdV1iDf",
pseudonymization_id="JKLM",
canceled=True,
positionid=2,
)
op.answers.create(question=question, answer='S')
return o
@pytest.fixture
def invoice(order):
testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
return generate_invoice(order)
TEST_INVOICE_RES = {
"order": "FOO",
"number": "DUMMY-00001",
"is_cancellation": False,
"invoice_from_name": "",
"invoice_from": "",
"invoice_from_zipcode": "",
"invoice_from_city": "",
"invoice_from_country": None,
"invoice_from_tax_id": "",
"invoice_from_vat_id": "",
"invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123",
"invoice_to_company": "Sample company",
"invoice_to_name": "",
"invoice_to_street": "",
"invoice_to_zipcode": "",
"invoice_to_city": "",
"invoice_to_state": "",
"invoice_to_country": "NZ",
"invoice_to_vat_id": "DE123",
"invoice_to_beneficiary": "",
"custom_field": None,
"date": "2017-12-10",
"refers": None,
"locale": "en",
"introductory_text": "",
"internal_reference": "",
"additional_text": "",
"payment_provider_text": "",
"footer_text": "",
"foreign_currency_display": None,
"foreign_currency_rate": None,
"foreign_currency_rate_date": None,
"lines": [
{
"position": 1,
"description": "Budget Ticket<br />Attendee: Peter",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_location': None,
'attendee_name': 'Peter',
'item': None,
'variation': None,
'fee_type': None,
'fee_internal_type': None,
"gross_value": "23.00",
"tax_value": "0.00",
"tax_name": "",
"tax_rate": "0.00"
},
{
"position": 2,
"description": "Payment fee",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_location': None,
'attendee_name': None,
'fee_type': "payment",
'fee_internal_type': None,
'item': None,
'variation': None,
"gross_value": "0.25",
"tax_value": "0.05",
"tax_name": "",
"tax_rate": "19.00"
}
]
}
@pytest.mark.django_db
def test_invoice_list(token_client, organizer, event, order, item, invoice):
res = dict(TEST_INVOICE_RES)
res['lines'][0]['item'] = item.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO'.format(organizer.slug, event.slug))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=BAR'.format(organizer.slug, event.slug))
assert [] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number={}'.format(
organizer.slug, event.slug, invoice.number))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?number=XXX'.format(
organizer.slug, event.slug))
assert [] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=en'.format(
organizer.slug, event.slug))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=de'.format(
organizer.slug, event.slug))
assert [] == resp.data['results']
with scopes_disabled():
ic = generate_cancellation(invoice)
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=false'.format(
organizer.slug, event.slug))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=true'.format(
organizer.slug, event.slug))
assert len(resp.data['results']) == 1
assert resp.data['results'][0]['number'] == ic.number
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format(
organizer.slug, event.slug, invoice.number))
assert len(resp.data['results']) == 1
assert resp.data['results'][0]['number'] == ic.number
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format(
organizer.slug, event.slug, ic.number))
assert [] == resp.data['results']
@pytest.mark.django_db
def test_invoice_detail(token_client, organizer, event, item, invoice):
res = dict(TEST_INVOICE_RES)
res['lines'][0]['item'] = item.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/{}/'.format(organizer.slug, event.slug,
invoice.number))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_invoice_regenerate(token_client, organizer, event, invoice):
organizer.settings.invoice_regenerate_allowed = True
with scopes_disabled():
InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd")
with freezegun.freeze_time("2017-12-10"):
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/regenerate/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 204
invoice.refresh_from_db()
assert "ACME Ltd" in invoice.invoice_to
@pytest.mark.django_db
def test_invoice_reissue(token_client, organizer, event, invoice):
with scopes_disabled():
InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd")
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/reissue/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 204
invoice.refresh_from_db()
assert "ACME Ltd" not in invoice.invoice_to
with scopes_disabled():
assert invoice.order.invoices.count() == 3
invoice = invoice.order.invoices.last()
assert "ACME Ltd" in invoice.invoice_to

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff