Add writable questions API methods

This commit is contained in:
Raphael Michel
2018-03-12 19:17:05 +01:00
parent aef77965e7
commit dffc82781b
12 changed files with 844 additions and 21 deletions

View File

@@ -132,21 +132,73 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'description', 'position', 'is_addon')
class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
class QuestionOptionSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(allow_null=True)
class Meta:
model = QuestionOption
fields = ('id', 'identifier', 'answer')
fields = ('id', 'identifier', 'answer', 'position')
def validate_identifier(self, value):
QuestionOption.clean_identifier(self.context['event'], value, self.instance)
return value
class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(allow_null=True)
class Meta:
model = QuestionOption
fields = ('id', 'identifier', 'answer', 'position')
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True)
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
return value
def validate(self, data):
data = super().validate(data)
if self.instance and 'options' in data:
raise ValidationError(_('Updating options via PATCH/PUT is not supported. Please use the dedicated'
' nested endpoint.'))
event = self.context['event']
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Question.clean_items(event, full_data.get('items'))
return data
def validate_options(self, value):
if not self.instance:
known = []
for opt_data in value:
if opt_data.get('identifier'):
QuestionOption.clean_identifier(self.context['event'], opt_data.get('identifier'), self.instance,
known)
known.append(opt_data.get('identifier'))
return value
@transaction.atomic
def create(self, validated_data):
options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items')
question = Question.objects.create(**validated_data)
question.items.set(items)
for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data)
return question
class QuotaSerializer(I18nAwareModelSerializer):

View File

@@ -29,6 +29,9 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet)
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
question_router = routers.DefaultRouter()
question_router.register(r'options', item.QuestionOptionViewSet)
item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
@@ -44,6 +47,8 @@ urlpatterns = [
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
include(question_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
include(checkinlist_router.urls)),
]

View File

@@ -10,10 +10,12 @@ from rest_framework.response import Response
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer,
ItemVariationSerializer, QuestionSerializer, QuotaSerializer,
ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
)
from pretix.base.models import (
Item, ItemAddOn, ItemCategory, ItemVariation, Question, Quota,
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.dicts import merge_dicts
@@ -214,7 +216,7 @@ class ItemCategoryViewSet(viewsets.ReadOnlyModelViewSet):
return self.request.event.categories.all()
class QuestionViewSet(viewsets.ReadOnlyModelViewSet):
class QuestionViewSet(viewsets.ModelViewSet):
serializer_class = QuestionSerializer
queryset = Question.objects.none()
filter_backends = (OrderingFilter,)
@@ -225,6 +227,85 @@ class QuestionViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.question.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
)
super().perform_destroy(instance)
class QuestionOptionViewSet(viewsets.ModelViewSet):
serializer_class = QuestionOptionSerializer
queryset = QuestionOption.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('position',)
permission = 'can_change_items'
write_permission = 'can_change_items'
def get_queryset(self):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return q.options.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return ctx
def perform_create(self, serializer):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
serializer.save(question=q)
q.log_action(
'pretix.event.question.option.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.question.log_action(
'pretix.event.question.option.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
def perform_destroy(self, instance):
instance.question.log_action(
'pretix.event.question.option.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data={'id': instance.pk}
)
super().perform_destroy(instance)
class QuotaFilter(FilterSet):
class Meta:

View File

@@ -716,7 +716,14 @@ class Question(LoggedModel):
self.event.cache.clear()
def clean_identifier(self, code):
if Question.objects.filter(event=self.event, identifier=code).exclude(pk=self.pk).exists():
Question._clean_identifier(self.event, code, self)
@staticmethod
def _clean_identifier(event, code, instance=None):
qs = Question.objects.filter(event=event, identifier=code)
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
raise ValidationError(_('This identifier is already used for a different question.'))
def save(self, *args, **kwargs):
@@ -796,6 +803,12 @@ class Question(LoggedModel):
return answer
@staticmethod
def clean_items(event, items):
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options')
@@ -816,6 +829,14 @@ class QuestionOption(models.Model):
break
super().save(*args, **kwargs)
@staticmethod
def clean_identifier(event, code, instance=None, known=[]):
qs = QuestionOption.objects.filter(question__event=event, identifier=code)
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists() or code in known:
raise ValidationError(_('The identifier "{}" is already used for a different option.').format(code))
class Meta:
verbose_name = _("Question option")
verbose_name_plural = _("Question options")

View File

@@ -8,7 +8,7 @@ from pytz import UTC
from pretix.base.models import (
CartPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Order,
OrderPosition, Quota,
OrderPosition, Question, QuestionOption, Quota,
)
from pretix.base.models.orders import OrderFee
@@ -843,6 +843,11 @@ def addon(item, category):
return item.addons.create(addon_category=category, min_count=0, max_count=10, position=1)
@pytest.fixture
def option(question):
return question.options.create(answer='XL', identifier='LVETRWVU')
TEST_ADDONS_RES = {
"min_count": 0,
"max_count": 10,
@@ -1200,7 +1205,7 @@ def test_quota_availability(token_client, organizer, event, quota, item):
def question(event, item):
q = event.questions.create(question="T-Shirt size", type="C", identifier="ABC")
q.items.add(item)
q.options.create(answer="XL", identifier="FOO")
q.options.create(answer="XL", identifier="LVETRWVU")
return q
@@ -1215,7 +1220,8 @@ TEST_QUESTION_RES = {
"options": [
{
"id": 0,
"identifier": "FOO",
"position": 0,
"identifier": "LVETRWVU",
"answer": {"en": "XL"}
}
]
@@ -1246,3 +1252,240 @@ def test_question_detail(token_client, organizer, event, question, item):
question.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_question_create(token_client, organizer, event, event2, item):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug),
{
"question": "What's your name?",
"type": "S",
"required": True,
"items": [item.pk],
"position": 0,
"ask_during_checkin": False,
"identifier": None
},
format='json'
)
assert resp.status_code == 201
question = Question.objects.get(pk=resp.data['id'])
assert question.question == "What's your name?"
assert question.type == "S"
assert question.identifier is not None
assert len(question.items.all()) == 1
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event2.slug),
{
"question": "What's your name?",
"type": "S",
"required": True,
"items": [item.pk],
"position": 0,
"ask_during_checkin": False,
"identifier": None
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["One or more items do not belong to this event."]}'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug),
{
"question": "What's your name?",
"type": "S",
"required": True,
"items": [item.pk],
"position": 0,
"ask_during_checkin": False,
"identifier": question.identifier
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"identifier":["This identifier is already used for a different question."]}'
@pytest.mark.django_db
def test_question_update(token_client, organizer, event, question):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk),
{
"question": "What's your shoe size?",
"type": "N",
},
format='json'
)
print(resp.content)
assert resp.status_code == 200
question = Question.objects.get(pk=resp.data['id'])
assert question.question == "What's your shoe size?"
assert question.type == "N"
@pytest.mark.django_db
def test_question_update_options(token_client, organizer, event, question, item):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk),
{
"options": [
]
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating options via PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
@pytest.mark.django_db
def test_question_delete(token_client, organizer, event, question):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, question.pk))
assert resp.status_code == 204
assert not event.questions.filter(pk=question.id).exists()
TEST_OPTIONS_RES = {
"identifier": "LVETRWVU",
"answer": {"en": "XL"},
"position": 0
}
@pytest.mark.django_db
def test_options_list(token_client, organizer, event, question, option):
res = dict(TEST_OPTIONS_RES)
res["id"] = option.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/{}/options/'.format(
organizer.slug, event.slug, question.pk)
)
assert resp.status_code == 200
assert res['identifier'] == resp.data['results'][0]['identifier']
assert res['answer'] == resp.data['results'][0]['answer']
@pytest.mark.django_db
def test_options_detail(token_client, organizer, event, question, option):
res = dict(TEST_OPTIONS_RES)
res["id"] = option.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format(
organizer.slug, event.slug, question.pk, option.pk
))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_options_create(token_client, organizer, event, question):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/{}/options/'.format(organizer.slug, event.slug, question.pk),
{
"identifier": "DFEMJWMJ",
"answer": "A",
"position": 0
},
format='json'
)
assert resp.status_code == 201
option = QuestionOption.objects.get(pk=resp.data['id'])
assert option.answer == "A"
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/{}/options/'.format(organizer.slug, event.slug, question.pk),
{
"identifier": "DFEMJWMJ",
"answer": "A",
"position": 0
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"identifier":["The identifier \\"DFEMJWMJ\\" is already used for a different option."]}'
@pytest.mark.django_db
def test_options_update(token_client, organizer, event, question, option):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format(organizer.slug, event.slug, question.pk, option.pk),
{
"answer": "B",
},
format='json'
)
assert resp.status_code == 200
a = QuestionOption.objects.get(pk=option.pk)
assert a.answer == "B"
@pytest.mark.django_db
def test_options_delete(token_client, organizer, event, question, option):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/questions/{}/options/{}/'.format(
organizer.slug, event.slug, question.pk, option.pk
))
assert resp.status_code == 204
assert not question.options.filter(pk=option.id).exists()
@pytest.mark.django_db
def test_question_create_with_option(token_client, organizer, event, item):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug),
{
"question": "What's your name?",
"type": "S",
"required": True,
"items": [item.pk],
"position": 0,
"ask_during_checkin": False,
"identifier": None,
"options": [
{
"identifier": None,
"answer": {"en": "A"},
"position": 0,
},
{
"identifier": None,
"answer": {"en": "B"},
"position": 1,
},
]
},
format='json'
)
assert resp.status_code == 201
question = Question.objects.get(pk=resp.data['id'])
assert str(question.options.first().answer) == "A"
assert question.options.first().identifier is not None
assert str(question.options.last().answer) == "B"
assert 2 == question.options.count()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug),
{
"question": "What's your name?",
"type": "S",
"required": True,
"items": [item.pk],
"position": 0,
"ask_during_checkin": False,
"identifier": None,
"options": [
{
"identifier": "ABC",
"answer": {"en": "A"},
"position": 0,
},
{
"identifier": "ABC",
"answer": {"en": "B"},
"position": 1,
},
]
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"options":["The identifier \\"ABC\\" is already used for a different option."]}'