diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 02dea7043..0fc3d2ab8 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1706,6 +1706,67 @@ Order position ticket download Manipulating individual positions --------------------------------- +.. versionchanged:: 3.15 + + The ``PATCH`` method has been added for individual positions. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ + + Updates specific fields on an order position. Currently, only the following fields are supported: + + * ``attendee_email`` + + * ``attendee_name_parts`` or ``attendee_name`` + + * ``company`` + + * ``street`` + + * ``zipcode`` + + * ``city`` + + * ``country`` + + * ``state`` + + * ``answers``: If specified, you will need to provide **all** answers for this order position. + Validation is handled the same way as when creating orders through the API. You are therefore + expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier`` + and ``option_identifiers`` will be ignored. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "attendee_email": "other@example.org" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + (Full order resource, see above.) + + :param organizer: The ``slug`` field of the organizer of the event + :param event: The ``slug`` field of the event + :param id: The ``id`` field of the order position to update + + :statuscode 200: no error + :statuscode 400: The order could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. + .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ Deletes an order position, identified by its internal ID. diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index cae4b92af..3aab61cd6 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.core.files import File from django.utils.crypto import get_random_string from django.utils.timezone import now from django.utils.translation import gettext_lazy @@ -100,8 +101,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): for answ_data in answers_data: options = answ_data.pop('options') - answ = cp.answers.create(**answ_data) - answ.options.add(*options) + if isinstance(answ_data['answer'], File): + an = answ_data.pop('answer') + answ = cp.answers.create(**answ_data, answer='') + answ.file.save(an.name, an, save=False) + answ.answer = 'file://' + answ.file.name + answ.save() + else: + answ = cp.answers.create(**answ_data) + answ.options.add(*options) return cp def validate_cart_id(self, cid): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 11e5ca0b8..babadf1bb 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -3,6 +3,7 @@ from collections import Counter, defaultdict from decimal import Decimal import pycountry +from django.core.files import File from django.db.models import F, Q from django.utils.timezone import now from django.utils.translation import gettext_lazy @@ -17,8 +18,9 @@ from pretix.base.channels import get_all_sales_channels from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( - Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, - OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, + CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, + ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat, + SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret, @@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field): class AnswerQuestionOptionsIdentifierField(serializers.Field): def to_representation(self, instance: QuestionAnswer): - return [o.identifier for o in instance.options.all()] - - -class AnswerQuestionOptionsField(serializers.Field): - def to_representation(self, instance: QuestionAnswer): - return [o.pk for o in instance.options.all()] + if isinstance(instance, WrappedModel) or instance.pk: + return [o.identifier for o in instance.options.all()] + return [] class InlineSeatSerializer(I18nAwareModelSerializer): @@ -112,12 +111,91 @@ class InlineSeatSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer): question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) - options = AnswerQuestionOptionsField(source='*', read_only=True) class Meta: model = QuestionAnswer fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers') + 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 _handle_file_upload(self, data): + try: + ao = self.context["request"].user or self.context["request"].auth + cf = CachedFile.objects.get( + session_key=f'api-upload-{str(type(ao))}-{ao.pk}', + file__isnull=False, + pk=data['answer'][len("file:"):], + ) + except (ValidationError, IndexError): # invalid uuid + raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data)) + except CachedFile.DoesNotExist: + raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data)) + + allowed_types = ( + 'image/png', 'image/jpeg', 'image/gif', 'application/pdf' + ) + if cf.type not in allowed_types: + raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data)) + if cf.file.size > 10 * 1024 * 1024: + raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data)) + + data['options'] = [] + data['answer'] = cf.file + return data + + def validate(self, data): + if data.get('question').type == Question.TYPE_FILE: + return self._handle_file_upload(data) + 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.' + ) + for o in data.get('options'): + if o.question_id != data.get('question').pk: + raise ValidationError( + 'The specified option does not belong to 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 CheckinSerializer(I18nAwareModelSerializer): class Meta: @@ -205,13 +283,14 @@ class PdfDataSerializer(serializers.Field): class OrderPositionSerializer(I18nAwareModelSerializer): - checkins = CheckinSerializer(many=True) + checkins = CheckinSerializer(many=True, read_only=True) answers = AnswerSerializer(many=True) - downloads = PositionDownloadsField(source='*') + downloads = PositionDownloadsField(source='*', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True) - pdf_data = PdfDataSerializer(source='*') + pdf_data = PdfDataSerializer(source='*', read_only=True) seat = InlineSeatSerializer(read_only=True) country = CompatibleCountryField(source='*') + attendee_name = serializers.CharField(required=False) class Meta: model = OrderPosition @@ -219,12 +298,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer): 'company', 'street', 'zipcode', 'city', 'country', 'state', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled') + read_only_fields = ( + 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', + 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', + 'seat', 'canceled' + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true': self.fields.pop('pdf_data') + 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 + 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(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 RequireAttentionField(serializers.Field): def to_representation(self, instance: OrderPosition): @@ -425,7 +591,17 @@ class OrderSerializer(I18nAwareModelSerializer): return instance +class AnswerQuestionOptionsField(serializers.Field): + def to_representation(self, instance: QuestionAnswer): + return [o.pk for o in instance.options.all()] + + +class SimulatedAnswerSerializer(AnswerSerializer): + options = AnswerQuestionOptionsField(read_only=True, source='*') + + class SimulatedOrderPositionSerializer(OrderPositionSerializer): + answers = SimulatedAnswerSerializer(many=True) addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid') @@ -452,62 +628,8 @@ class PriceCalcSerializer(serializers.Serializer): del self.fields['subevent'] -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 AnswerCreateSerializer(AnswerSerializer): + pass class OrderFeeCreateSerializer(I18nAwareModelSerializer): @@ -1044,8 +1166,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos.save() for answ_data in answers_data: options = answ_data.pop('options', []) - answ = pos.answers.create(**answ_data) - answ.options.add(*options) + + if isinstance(answ_data['answer'], File): + an = answ_data.pop('answer') + answ = pos.answers.create(**answ_data, answer='') + answ.file.save(an.name, an, save=False) + answ.answer = 'file://' + answ.file.name + answ.save() + else: + answ = pos.answers.create(**answ_data) + answ.options.add(*options) pos_map[pos.positionid] = pos if not simulate: diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 616d47ce2..c6101b750 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -763,7 +763,7 @@ with scopes_disabled(): } -class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): +class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderPositionSerializer queryset = OrderPosition.all.none() filter_backends = (DjangoFilterBackend, OrderingFilter) @@ -783,6 +783,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS }, } + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + def get_queryset(self): if self.request.query_params.get('include_canceled_positions', 'false') == 'true': qs = OrderPosition.all @@ -951,6 +956,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS 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): + with transaction.atomic(): + old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data + 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) + class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderPaymentSerializer diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 092d7097e..799bb43f7 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1224,6 +1224,9 @@ class AbstractPosition(models.Model): else self.variation.quotas.filter(subevent=self.subevent)) def save(self, *args, **kwargs): + update_fields = kwargs.get('update_fields', []) + if 'attendee_name_parts' in update_fields: + update_fields.append('attendee_name_cached') self.attendee_name_cached = self.attendee_name if self.attendee_name_parts is None: self.attendee_name_parts = {} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index b4789fdee..4769d5ad0 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1634,10 +1634,16 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView): self.invoice_form.save() self.order.log_action('pretix.event.order.modified', { 'invoice_data': self.invoice_form.cleaned_data, - 'data': [{ - k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k), File) else f.cleaned_data.get(k)) - for k in f.changed_data - } for f in self.forms] + 'data': [ + dict( + position=f.orderpos.pk, + **{ + k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k), + File) else f.cleaned_data.get(k)) + for k in f.changed_data + } + ) for f in self.forms + ] }, user=request.user) if self.invoice_form.has_changed(): success_message = ('The invoice address has been updated. If you want to generate a new invoice, ' diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index e075274c4..c0130dbb1 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -4,6 +4,7 @@ from decimal import Decimal from unittest import mock import pytest +from django.core.files.base import ContentFile from django.utils.timezone import now from django_scopes import scopes_disabled from pytz import UTC @@ -434,6 +435,19 @@ def test_cartpos_create_answer_validation(token_client, organizer, event, item, assert resp.status_code == 400 assert resp.data == {'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]} + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + res['answers'][0]['options'] = [] + res['answers'][0]['answer'] = file_id_png question.type = Question.TYPE_FILE question.save() resp = token_client.post( @@ -441,8 +455,12 @@ def test_cartpos_create_answer_validation(token_client, organizer, event, item, organizer.slug, event.slug ), format='json', data=res ) - assert resp.status_code == 400 - assert resp.data == {'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]} + assert resp.status_code == 201 + with scopes_disabled(): + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.file + assert answ.answer.startswith("file://") question.type = Question.TYPE_CHOICE_MULTIPLE question.save() diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 77c63e839..6e15d7c25 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -6,6 +6,7 @@ from unittest import mock import pytest from django.core import mail as djmail +from django.core.files.base import ContentFile from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -2692,6 +2693,21 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu assert resp.data == {'positions': [ {'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]} + with scopes_disabled(): + question2.options.create(answer="L") + with scopes_disabled(): + res['positions'][0]['answers'][0]['options'] = [ + question2.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': ['The specified option does not belong to this question.']}]}]} + with scopes_disabled(): question.options.create(answer="L") with scopes_disabled(): @@ -2708,6 +2724,19 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu assert resp.data == { 'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]} + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + res['positions'][0]['answers'][0]['options'] = [] + res['positions'][0]['answers'][0]['answer'] = file_id_png question.type = Question.TYPE_FILE question.save() resp = token_client.post( @@ -2715,9 +2744,13 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu 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.']}]}]} + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + pos = o.positions.first() + answ = pos.answers.first() + assert answ.file + assert answ.answer.startswith("file://") question.type = Question.TYPE_CHOICE_MULTIPLE question.save() @@ -4609,3 +4642,254 @@ def test_revoked_secret_list(token_client, organizer, event): )) assert resp.status_code == 200 assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_position_update_ignore_fields(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data={ + 'price': '99.99' + } + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.price == Decimal('23.00') + + +@pytest.mark.django_db +def test_position_update_only_partial(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + resp = token_client.put( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data={ + 'price': '99.99' + } + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_position_update(token_client, organizer, event, order, question): + with scopes_disabled(): + op = order.positions.first() + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + opt = question.options.create(answer="L") + payload = { + 'company': 'VILE', + 'attendee_name_parts': { + 'full_name': 'Max Mustermann' + }, + 'street': 'Sesame Street 21', + 'zipcode': '99999', + 'city': 'Springfield', + 'country': 'US', + 'state': 'CA', + 'attendee_email': 'foo@example.org', + 'answers': [ + { + 'question': question.pk, + 'answer': 'ignored', + 'options': [opt.pk] + } + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + assert resp.data['answers'] == [ + { + 'question': question.pk, + 'question_identifier': question.identifier, + 'answer': 'L', + 'options': [opt.pk], + 'option_identifiers': [opt.identifier], + } + ] + op.refresh_from_db() + assert op.company == 'VILE' + assert op.attendee_name_cached == 'Max Mustermann' + assert op.attendee_name_parts == { + '_scheme': 'full', + 'full_name': 'Max Mustermann' + } + with scopes_disabled(): + assert op.answers.get().answer == 'L' + assert op.street == 'Sesame Street 21' + assert op.zipcode == '99999' + assert op.city == 'Springfield' + assert str(op.country) == 'US' + assert op.state == 'CA' + assert op.attendee_email == 'foo@example.org' + le = order.all_logentries().last() + assert le.action_type == 'pretix.event.order.modified' + assert le.parsed_data == { + 'data': [ + { + 'position': op.pk, + 'company': 'VILE', + 'attendee_name_parts': { + '_scheme': 'full', + 'full_name': 'Max Mustermann' + }, + 'street': 'Sesame Street 21', + 'zipcode': '99999', + 'city': 'Springfield', + 'country': 'US', + 'state': 'CA', + 'attendee_email': 'foo@example.org', + f'question_{question.pk}': 'L' + } + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert order.all_logentries().last().pk == le.pk + + +@pytest.mark.django_db +def test_position_update_legacy_name(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'attendee_name': 'Max Mustermann', + 'attendee_name_parts': { + '_legacy': 'maria' + }, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + payload = { + 'attendee_name': 'Max Mustermann', + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.attendee_name_cached == 'Max Mustermann' + assert op.attendee_name_parts == { + '_legacy': 'Max Mustermann' + } + with scopes_disabled(): + assert op.answers.count() == 1 # answer does not get deleted + + +@pytest.mark.django_db +def test_position_update_state_validation(token_client, organizer, event, order): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'country': 'DE', + 'state': 'BW' + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_position_update_question_handling(token_client, organizer, event, order, question): + with scopes_disabled(): + op = order.positions.first() + payload = { + 'answers': [ + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + payload = { + 'answers': [ + { + 'question': question.pk, + 'answer': 'FOOBAR', + }, + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert op.answers.count() == 1 + payload = { + 'answers': [ + ] + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert op.answers.count() == 0 + + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + payload = { + 'answers': [ + { + "question": question.id, + "answer": file_id_png + } + ] + } + question.type = Question.TYPE_FILE + question.save() + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + answ = op.answers.get() + assert answ.file + assert answ.answer.startswith("file://")