Refs #102 -- Added multiple-choice question types

This commit is contained in:
Raphael Michel
2016-03-26 12:47:44 +01:00
parent eed74545bc
commit 1cd0c1f53b
12 changed files with 306 additions and 25 deletions
+2 -2
View File
@@ -225,10 +225,10 @@ class I18nFormField(forms.MultiValueField):
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
} }
self.langcodes = kwargs.pop('langcodes', [l[0] for l in settings.LANGUAGES]) self.langcodes = kwargs.pop('langcodes', [l[0] for l in settings.LANGUAGES])
self.one_required = kwargs['required'] self.one_required = kwargs.get('required', True)
kwargs['required'] = False kwargs['required'] = False
kwargs['widget'] = kwargs['widget']( kwargs['widget'] = kwargs['widget'](
langcodes=self.langcodes, field=self langcodes=self.langcodes, field=self, **kwargs.pop('widget_kwargs', {})
) )
defaults.update(**kwargs) defaults.update(**kwargs)
for lngcode in self.langcodes: for lngcode in self.langcodes:
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-03-26 11:04
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.i18n
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0017_auto_20160324_1615'),
]
operations = [
migrations.CreateModel(
name='QuestionOption',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', pretix.base.i18n.I18nCharField(verbose_name='Answer')),
],
),
migrations.AlterField(
model_name='item',
name='free_price',
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event.', verbose_name='Free price input'),
),
migrations.AlterField(
model_name='question',
name='type',
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list')], max_length=5, verbose_name='Question type'),
),
migrations.AddField(
model_name='questionoption',
name='question',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Question'),
),
migrations.AddField(
model_name='questionanswer',
name='option',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.QuestionOption'),
),
]
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-03-26 11:39
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0018_auto_20160326_1104'),
]
operations = [
migrations.RemoveField(
model_name='questionanswer',
name='option',
),
migrations.AddField(
model_name='questionanswer',
name='options',
field=models.ManyToManyField(blank=True, null=True, related_name='answers', to='pretixbase.QuestionOption'),
),
migrations.AlterField(
model_name='questionoption',
name='question',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='pretixbase.Question'),
),
]
+3 -2
View File
@@ -3,7 +3,8 @@ from .base import CachedFile, cachedfile_name
from .event import Event, EventLock, EventPermission, EventSetting from .event import Event, EventLock, EventPermission, EventSetting
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (
Item, ItemCategory, ItemVariation, Question, Quota, itempicture_upload_to, Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .orders import ( from .orders import (
@@ -19,5 +20,5 @@ __all__ = [
'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'AbstractPosition', 'OrderPosition', 'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'AbstractPosition', 'OrderPosition',
'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to', 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to',
'generate_secret', 'Voucher', 'LogEntry', 'InvoiceAddress', 'generate_position_secret', 'InvoiceLine', 'generate_secret', 'Voucher', 'LogEntry', 'InvoiceAddress', 'generate_position_secret', 'InvoiceLine',
'Invoice', 'invoice_filename' 'Invoice', 'invoice_filename', 'QuestionOption'
] ]
+13
View File
@@ -302,6 +302,7 @@ class Question(LoggedModel):
* a one-line string (``TYPE_STRING``) * a one-line string (``TYPE_STRING``)
* a multi-line string (``TYPE_TEXT``) * a multi-line string (``TYPE_TEXT``)
* a boolean (``TYPE_BOOLEAN``) * a boolean (``TYPE_BOOLEAN``)
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
:param event: The event this question belongs to :param event: The event this question belongs to
:type event: Event :type event: Event
@@ -317,11 +318,15 @@ class Question(LoggedModel):
TYPE_STRING = "S" TYPE_STRING = "S"
TYPE_TEXT = "T" TYPE_TEXT = "T"
TYPE_BOOLEAN = "B" TYPE_BOOLEAN = "B"
TYPE_CHOICE = "C"
TYPE_CHOICE_MULTIPLE = "M"
TYPE_CHOICES = ( TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")), (TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")), (TYPE_STRING, _("Text (one line)")),
(TYPE_TEXT, _("Multiline text")), (TYPE_TEXT, _("Multiline text")),
(TYPE_BOOLEAN, _("Yes/No")), (TYPE_BOOLEAN, _("Yes/No")),
(TYPE_CHOICE, _("Choose one from a list")),
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list"))
) )
event = models.ForeignKey( event = models.ForeignKey(
@@ -366,6 +371,14 @@ class Question(LoggedModel):
self.event.get_cache().clear() self.event.get_cache().clear()
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options')
answer = I18nCharField(verbose_name=_('Answer'))
def __str__(self):
return str(self.answer)
class Quota(LoggedModel): class Quota(LoggedModel):
""" """
A quota is a "pool of tickets". It is there to limit the number of items A quota is a "pool of tickets". It is there to limit the number of items
+4 -1
View File
@@ -12,7 +12,7 @@ from typing import List, Union
from ..decimal import round_decimal from ..decimal import round_decimal
from .base import CachedFile, LoggedModel from .base import CachedFile, LoggedModel
from .event import Event from .event import Event
from .items import Item, ItemVariation, Question, Quota from .items import Item, ItemVariation, Question, QuestionOption, Quota
def generate_secret(): def generate_secret():
@@ -286,6 +286,9 @@ class QuestionAnswer(models.Model):
question = models.ForeignKey( question = models.ForeignKey(
Question, related_name='answers' Question, related_name='answers'
) )
options = models.ManyToManyField(
QuestionOption, related_name='answers', blank=True
)
answer = models.TextField() answer = models.TextField()
def __str__(self): def __str__(self):
+19 -2
View File
@@ -5,8 +5,9 @@ from django.forms import BooleanField, ModelMultipleChoiceField
from django.utils.translation import ugettext as __, ugettext_lazy as _ from django.utils.translation import ugettext as __, ugettext_lazy as _
from pretix.base.forms import I18nModelForm from pretix.base.forms import I18nModelForm
from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, Quota, Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
) )
@@ -20,6 +21,12 @@ class CategoryForm(I18nModelForm):
class QuestionForm(I18nModelForm): class QuestionForm(I18nModelForm):
question = I18nFormField(
label=_("Question"),
widget_kwargs={'attrs': {'rows': 5}},
widget=I18nTextarea
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['items'].queryset = self.instance.event.items.all() self.fields['items'].queryset = self.instance.event.items.all()
@@ -34,10 +41,20 @@ class QuestionForm(I18nModelForm):
'items' 'items'
] ]
widgets = { widgets = {
'items': forms.CheckboxSelectMultiple 'items': forms.CheckboxSelectMultiple,
} }
class QuestionOptionForm(I18nModelForm):
class Meta:
model = QuestionOption
localized_fields = '__all__'
fields = [
'answer',
]
class QuotaForm(I18nModelForm): class QuotaForm(I18nModelForm):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load formset_tags %}
{% block title %}{% trans "Question" %}{% endblock %} {% block title %}{% trans "Question" %}{% endblock %}
{% block inside %} {% block inside %}
<h1>{% trans "Question" %}</h1> <h1>{% trans "Question" %}</h1>
@@ -23,6 +24,59 @@
accepted. If you want to allow both options, do not make this field required. accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %} {% endblocktrans %}
</div> </div>
<fieldset id="answer-options">
<legend>{% trans "Answer options" %}</legend>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
</fieldset>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %} {% trans "Save" %}
@@ -31,14 +85,17 @@
</form> </form>
<script type="text/javascript"> <script type="text/javascript">
$(function () { $(function () {
function alert_required_boolean() { function toggle_view() {
var show = $("#id_type").val() == "B" && $("#id_required").prop("checked"); var show = $("#id_type").val() == "C" || $("#id_type").val() == "M";
$("#answer-options").toggle(show);
show = $("#id_type").val() == "B" && $("#id_required").prop("checked");
$(".alert-required-boolean").toggle(show); $(".alert-required-boolean").toggle(show);
} }
$("#id_type").change(alert_required_boolean); $("#id_type").change(toggle_view);
$("#id_required").change(alert_required_boolean); $("#id_required").change(toggle_view);
alert_required_boolean(); toggle_view();
}); });
</script> </script>
{% endblock %} {% endblock %}
+76 -4
View File
@@ -14,11 +14,11 @@ from django.views.generic.edit import DeleteView
from pretix.base.i18n import I18nFormSet from pretix.base.i18n import I18nFormSet
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, Quota, Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
) )
from pretix.control.forms.item import ( from pretix.control.forms.item import (
CategoryForm, ItemCreateForm, ItemFormGeneral, ItemVariationForm, CategoryForm, ItemCreateForm, ItemFormGeneral, ItemVariationForm,
QuestionForm, QuotaForm, QuestionForm, QuestionOptionForm, QuotaForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required, EventPermissionRequiredMixin, event_permission_required,
@@ -272,7 +272,67 @@ class QuestionDelete(EventPermissionRequiredMixin, DeleteView):
}) })
class QuestionUpdate(EventPermissionRequiredMixin, UpdateView): class QuestionMixin:
@cached_property
def formset(self):
formsetclass = inlineformset_factory(
Question, QuestionOption,
form=QuestionOptionForm, formset=I18nFormSet,
can_order=False, can_delete=True, extra=0
)
return formsetclass(self.request.POST if self.request.method == "POST" else None,
queryset=(QuestionOption.objects.filter(question=self.get_object())
if self.get_object() else QuestionOption.objects.none()),
event=self.request.event)
def save_formset(self, obj):
if self.formset.is_valid():
for form in self.formset.initial_forms:
if form in self.formset.deleted_forms:
if not form.instance.pk:
continue
self.get_object().log_action(
'pretix.event.question.option.deleted', user=self.request.user, data={
'id': form.instance.pk
}
)
form.instance.delete()
form.instance.pk = None
elif form.has_changed():
form.instance.question = self.get_object()
form.save()
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
change_data['id'] = form.instance.pk
self.get_object().log_action(
'pretix.event.question.option.changed',
user=self.request.user, data=change_data
)
for form in self.formset.extra_forms:
if not form.has_changed():
continue
if self.formset._should_delete_form(form):
continue
form.instance.question = self.get_object()
form.save()
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
change_data['id'] = form.instance.pk
self.get_object().log_action(
'pretix.event.question.option.added',
user=self.request.user, data=change_data
)
return True
return False
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
return ctx
class QuestionUpdate(EventPermissionRequiredMixin, QuestionMixin, UpdateView):
model = Question model = Question
form_class = QuestionForm form_class = QuestionForm
template_name = 'pretixcontrol/items/question.html' template_name = 'pretixcontrol/items/question.html'
@@ -289,6 +349,10 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView):
@transaction.atomic() @transaction.atomic()
def form_valid(self, form): def form_valid(self, form):
if form.cleaned_data.get('type') in ('M', 'C'):
if not self.save_formset(self.get_object):
return self.get(self.request, *self.args, **self.kwargs)
if form.has_changed(): if form.has_changed():
self.object.log_action( self.object.log_action(
'pretix.event.question.changed', user=self.request.user, data={ 'pretix.event.question.changed', user=self.request.user, data={
@@ -305,7 +369,7 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView):
}) })
class QuestionCreate(EventPermissionRequiredMixin, CreateView): class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
model = Question model = Question
form_class = QuestionForm form_class = QuestionForm
template_name = 'pretixcontrol/items/question.html' template_name = 'pretixcontrol/items/question.html'
@@ -323,11 +387,19 @@ class QuestionCreate(EventPermissionRequiredMixin, CreateView):
'event': self.request.event.slug, 'event': self.request.event.slug,
}) })
def get_object(self):
return None
@transaction.atomic() @transaction.atomic()
def form_valid(self, form): def form_valid(self, form):
if form.cleaned_data.get('type') in ('M', 'C'):
if not self.save_formset(self.get_object):
return self.get(self.request, *self.args, **self.kwargs)
messages.success(self.request, _('The new question has been created.')) messages.success(self.request, _('The new question has been created.'))
ret = super().form_valid(form) ret = super().form_valid(form)
form.instance.log_action('pretix.event.question.added', user=self.request.user, data=dict(form.cleaned_data)) form.instance.log_action('pretix.event.question.added', user=self.request.user, data=dict(form.cleaned_data))
self.save_formset(form.instance)
return ret return ret
+24 -6
View File
@@ -76,7 +76,7 @@ class QuestionsForm(forms.Form):
if a.question_id == q.id if a.question_id == q.id
] ]
if answers: if answers:
initial = answers[0].answer initial = answers[0]
else: else:
initial = None initial = None
if q.type == Question.TYPE_BOOLEAN: if q.type == Question.TYPE_BOOLEAN:
@@ -87,27 +87,45 @@ class QuestionsForm(forms.Form):
else: else:
widget = forms.CheckboxInput() widget = forms.CheckboxInput()
initial = (initial == "True") if initial:
initialbool = (initial.answer == "True")
else:
initialbool = False
field = forms.BooleanField( field = forms.BooleanField(
label=q.question, required=q.required, label=q.question, required=q.required,
initial=initial, widget=widget initial=initialbool, widget=widget
) )
elif q.type == Question.TYPE_NUMBER: elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField( field = forms.DecimalField(
label=q.question, required=q.required, label=q.question, required=q.required,
initial=initial, min_value=Decimal('0.00') initial=initial.answer if initial else None,
min_value=Decimal('0.00')
) )
elif q.type == Question.TYPE_STRING: elif q.type == Question.TYPE_STRING:
field = forms.CharField( field = forms.CharField(
label=q.question, required=q.required, label=q.question, required=q.required,
initial=initial initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_TEXT: elif q.type == Question.TYPE_TEXT:
field = forms.CharField( field = forms.CharField(
label=q.question, required=q.required, label=q.question, required=q.required,
widget=forms.Textarea, widget=forms.Textarea,
initial=initial initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=q.options.all(),
label=q.question, required=q.required,
widget=forms.RadioSelect,
initial=initial.options.first() if initial else None,
)
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=q.options.all(),
label=q.question, required=q.required,
widget=forms.CheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
) )
field.question = q field.question = q
if answers: if answers:
+24 -3
View File
@@ -1,3 +1,4 @@
from django import forms
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
@@ -47,13 +48,33 @@ class QuestionsViewMixin:
if v == '': if v == '':
field.answer.delete() field.answer.delete()
else: else:
field.answer.answer = v self._save_to_answer(field, field.answer, v)
field.answer.save() field.answer.save()
elif v != '': elif v != '':
QuestionAnswer.objects.create( answer = QuestionAnswer(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None), cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None), orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question, question=field.question,
answer=v
) )
self._save_to_answer(field, answer, v)
answer.save()
return not failed return not failed
def _save_to_answer(self, field, answer, value):
if isinstance(field, forms.ModelMultipleChoiceField):
answstr = ", ".join([str(o) for o in value])
if not answer.pk:
answer.save()
else:
answer.options.clear()
answer.answer = answstr
answer.options.add(*value)
elif isinstance(field, forms.ModelChoiceField):
if not answer.pk:
answer.save()
else:
answer.options.clear()
answer.options.add(value)
answer.answer = value.answer
else:
answer.answer = value
+4
View File
@@ -74,6 +74,10 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
padding-left: 0; padding-left: 0;
} }
.question-option-row {
padding: 5px 0;
}
.i18n-form-group input, .i18n-form-group input,
.i18n-form-group textarea { .i18n-form-group textarea {
.border-top-radius(0px); .border-top-radius(0px);