API: Allow to modify order position information (#1904)

This commit is contained in:
Raphael Michel
2021-01-13 14:18:58 +01:00
committed by GitHub
parent 70bf422537
commit d391312aab
8 changed files with 635 additions and 82 deletions
+61
View File
@@ -1706,6 +1706,67 @@ Order position ticket download
Manipulating individual positions 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)/ .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Deletes an order position, identified by its internal ID. Deletes an order position, identified by its internal ID.
+10 -2
View File
@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
from django.core.files import File
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
@@ -100,8 +101,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
for answ_data in answers_data: for answ_data in answers_data:
options = answ_data.pop('options') options = answ_data.pop('options')
answ = cp.answers.create(**answ_data) if isinstance(answ_data['answer'], File):
answ.options.add(*options) 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 return cp
def validate_cart_id(self, cid): def validate_cart_id(self, cid):
+200 -70
View File
@@ -3,6 +3,7 @@ from collections import Counter, defaultdict
from decimal import Decimal from decimal import Decimal
import pycountry import pycountry
from django.core.files import File
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy 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.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
SubEvent, TaxRule, Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
@@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field):
class AnswerQuestionOptionsIdentifierField(serializers.Field): class AnswerQuestionOptionsIdentifierField(serializers.Field):
def to_representation(self, instance: QuestionAnswer): def to_representation(self, instance: QuestionAnswer):
return [o.identifier for o in instance.options.all()] if isinstance(instance, WrappedModel) or instance.pk:
return [o.identifier for o in instance.options.all()]
return []
class AnswerQuestionOptionsField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return [o.pk for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer): class InlineSeatSerializer(I18nAwareModelSerializer):
@@ -112,12 +111,91 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
options = AnswerQuestionOptionsField(source='*', read_only=True)
class Meta: class Meta:
model = QuestionAnswer model = QuestionAnswer
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers') 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 CheckinSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
@@ -205,13 +283,14 @@ class PdfDataSerializer(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True) checkins = CheckinSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*') downloads = PositionDownloadsField(source='*', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', 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) seat = InlineSeatSerializer(read_only=True)
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
@@ -219,12 +298,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state', 'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled') '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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true': if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields.pop('pdf_data') 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): class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition): def to_representation(self, instance: OrderPosition):
@@ -425,7 +591,17 @@ class OrderSerializer(I18nAwareModelSerializer):
return instance 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): class SimulatedOrderPositionSerializer(OrderPositionSerializer):
answers = SimulatedAnswerSerializer(many=True)
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid') addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
@@ -452,62 +628,8 @@ class PriceCalcSerializer(serializers.Serializer):
del self.fields['subevent'] del self.fields['subevent']
class AnswerCreateSerializer(I18nAwareModelSerializer): class AnswerCreateSerializer(AnswerSerializer):
pass
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 OrderFeeCreateSerializer(I18nAwareModelSerializer):
@@ -1044,8 +1166,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.save() pos.save()
for answ_data in answers_data: for answ_data in answers_data:
options = answ_data.pop('options', []) 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 pos_map[pos.positionid] = pos
if not simulate: if not simulate:
+44 -1
View File
@@ -763,7 +763,7 @@ with scopes_disabled():
} }
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) 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): def get_queryset(self):
if self.request.query_params.get('include_canceled_positions', 'false') == 'true': if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
qs = OrderPosition.all qs = OrderPosition.all
@@ -951,6 +956,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
raise ValidationError(str(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): class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer serializer_class = OrderPaymentSerializer
+3
View File
@@ -1224,6 +1224,9 @@ class AbstractPosition(models.Model):
else self.variation.quotas.filter(subevent=self.subevent)) else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs): 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 self.attendee_name_cached = self.attendee_name
if self.attendee_name_parts is None: if self.attendee_name_parts is None:
self.attendee_name_parts = {} self.attendee_name_parts = {}
+10 -4
View File
@@ -1634,10 +1634,16 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
self.invoice_form.save() self.invoice_form.save()
self.order.log_action('pretix.event.order.modified', { self.order.log_action('pretix.event.order.modified', {
'invoice_data': self.invoice_form.cleaned_data, 'invoice_data': self.invoice_form.cleaned_data,
'data': [{ 'data': [
k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k), File) else f.cleaned_data.get(k)) dict(
for k in f.changed_data position=f.orderpos.pk,
} for f in self.forms] **{
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) }, user=request.user)
if self.invoice_form.has_changed(): if self.invoice_form.has_changed():
success_message = ('The invoice address has been updated. If you want to generate a new invoice, ' success_message = ('The invoice address has been updated. If you want to generate a new invoice, '
+20 -2
View File
@@ -4,6 +4,7 @@ from decimal import Decimal
from unittest import mock from unittest import mock
import pytest import pytest
from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pytz import UTC 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.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]} 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.type = Question.TYPE_FILE
question.save() question.save()
resp = token_client.post( resp = token_client.post(
@@ -441,8 +455,12 @@ def test_cartpos_create_answer_validation(token_client, organizer, event, item,
organizer.slug, event.slug organizer.slug, event.slug
), format='json', data=res ), format='json', data=res
) )
assert resp.status_code == 400 assert resp.status_code == 201
assert resp.data == {'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]} 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.type = Question.TYPE_CHOICE_MULTIPLE
question.save() question.save()
+287 -3
View File
@@ -6,6 +6,7 @@ from unittest import mock
import pytest import pytest
from django.core import mail as djmail from django.core import mail as djmail
from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scopes_disabled 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': [ assert resp.data == {'positions': [
{'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]} {'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(): with scopes_disabled():
question.options.create(answer="L") question.options.create(answer="L")
with scopes_disabled(): with scopes_disabled():
@@ -2708,6 +2724,19 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
assert resp.data == { assert resp.data == {
'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]} '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.type = Question.TYPE_FILE
question.save() question.save()
resp = token_client.post( resp = token_client.post(
@@ -2715,9 +2744,13 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
organizer.slug, event.slug organizer.slug, event.slug
), format='json', data=res ), format='json', data=res
) )
assert resp.status_code == 400 assert resp.status_code == 201
assert resp.data == { with scopes_disabled():
'positions': [{'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]}]} 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.type = Question.TYPE_CHOICE_MULTIPLE
question.save() question.save()
@@ -4609,3 +4642,254 @@ def test_revoked_secret_list(token_client, organizer, event):
)) ))
assert resp.status_code == 200 assert resp.status_code == 200
assert [res] == resp.data['results'] 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://")