mirror of
https://github.com/pretix/pretix.git
synced 2026-09-15 16:34:42 +00:00
API: Allow to modify order position information (#1904)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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, '
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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://")
|
||||||
|
|||||||
Reference in New Issue
Block a user