mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
Refs #102 -- Added multiple-choice question types
This commit is contained in:
@@ -225,10 +225,10 @@ class I18nFormField(forms.MultiValueField):
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
}
|
||||
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['widget'] = kwargs['widget'](
|
||||
langcodes=self.langcodes, field=self
|
||||
langcodes=self.langcodes, field=self, **kwargs.pop('widget_kwargs', {})
|
||||
)
|
||||
defaults.update(**kwargs)
|
||||
for lngcode in self.langcodes:
|
||||
|
||||
45
src/pretix/base/migrations/0018_auto_20160326_1104.py
Normal file
45
src/pretix/base/migrations/0018_auto_20160326_1104.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0019_auto_20160326_1139.py
Normal file
30
src/pretix/base/migrations/0019_auto_20160326_1139.py
Normal file
@@ -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,7 +3,8 @@ from .base import CachedFile, cachedfile_name
|
||||
from .event import Event, EventLock, EventPermission, EventSetting
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
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 .orders import (
|
||||
@@ -19,5 +20,5 @@ __all__ = [
|
||||
'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'AbstractPosition', 'OrderPosition',
|
||||
'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to',
|
||||
'generate_secret', 'Voucher', 'LogEntry', 'InvoiceAddress', 'generate_position_secret', 'InvoiceLine',
|
||||
'Invoice', 'invoice_filename'
|
||||
'Invoice', 'invoice_filename', 'QuestionOption'
|
||||
]
|
||||
|
||||
@@ -302,6 +302,7 @@ class Question(LoggedModel):
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
* a multi-line string (``TYPE_TEXT``)
|
||||
* a boolean (``TYPE_BOOLEAN``)
|
||||
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
|
||||
|
||||
:param event: The event this question belongs to
|
||||
:type event: Event
|
||||
@@ -317,11 +318,15 @@ class Question(LoggedModel):
|
||||
TYPE_STRING = "S"
|
||||
TYPE_TEXT = "T"
|
||||
TYPE_BOOLEAN = "B"
|
||||
TYPE_CHOICE = "C"
|
||||
TYPE_CHOICE_MULTIPLE = "M"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
(TYPE_TEXT, _("Multiline text")),
|
||||
(TYPE_BOOLEAN, _("Yes/No")),
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list"))
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -366,6 +371,14 @@ class Question(LoggedModel):
|
||||
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):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import List, Union
|
||||
from ..decimal import round_decimal
|
||||
from .base import CachedFile, LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation, Question, Quota
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
|
||||
def generate_secret():
|
||||
@@ -286,6 +286,9 @@ class QuestionAnswer(models.Model):
|
||||
question = models.ForeignKey(
|
||||
Question, related_name='answers'
|
||||
)
|
||||
options = models.ManyToManyField(
|
||||
QuestionOption, related_name='answers', blank=True
|
||||
)
|
||||
answer = models.TextField()
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -5,8 +5,9 @@ from django.forms import BooleanField, ModelMultipleChoiceField
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea
|
||||
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):
|
||||
question = I18nFormField(
|
||||
label=_("Question"),
|
||||
widget_kwargs={'attrs': {'rows': 5}},
|
||||
widget=I18nTextarea
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
@@ -34,10 +41,20 @@ class QuestionForm(I18nModelForm):
|
||||
'items'
|
||||
]
|
||||
widgets = {
|
||||
'items': forms.CheckboxSelectMultiple
|
||||
'items': forms.CheckboxSelectMultiple,
|
||||
}
|
||||
|
||||
|
||||
class QuestionOptionForm(I18nModelForm):
|
||||
|
||||
class Meta:
|
||||
model = QuestionOption
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'answer',
|
||||
]
|
||||
|
||||
|
||||
class QuotaForm(I18nModelForm):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load formset_tags %}
|
||||
{% block title %}{% trans "Question" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Question" %}</h1>
|
||||
@@ -23,6 +24,59 @@
|
||||
accepted. If you want to allow both options, do not make this field required.
|
||||
{% endblocktrans %}
|
||||
</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">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
@@ -31,14 +85,17 @@
|
||||
</form>
|
||||
<script type="text/javascript">
|
||||
$(function () {
|
||||
function alert_required_boolean() {
|
||||
var show = $("#id_type").val() == "B" && $("#id_required").prop("checked");
|
||||
function toggle_view() {
|
||||
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);
|
||||
}
|
||||
|
||||
$("#id_type").change(alert_required_boolean);
|
||||
$("#id_required").change(alert_required_boolean);
|
||||
alert_required_boolean();
|
||||
$("#id_type").change(toggle_view);
|
||||
$("#id_required").change(toggle_view);
|
||||
toggle_view();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,11 +14,11 @@ from django.views.generic.edit import DeleteView
|
||||
|
||||
from pretix.base.i18n import I18nFormSet
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, Quota,
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.control.forms.item import (
|
||||
CategoryForm, ItemCreateForm, ItemFormGeneral, ItemVariationForm,
|
||||
QuestionForm, QuotaForm,
|
||||
QuestionForm, QuestionOptionForm, QuotaForm,
|
||||
)
|
||||
from pretix.control.permissions import (
|
||||
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
|
||||
form_class = QuestionForm
|
||||
template_name = 'pretixcontrol/items/question.html'
|
||||
@@ -289,6 +349,10 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView):
|
||||
|
||||
@transaction.atomic()
|
||||
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():
|
||||
self.object.log_action(
|
||||
'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
|
||||
form_class = QuestionForm
|
||||
template_name = 'pretixcontrol/items/question.html'
|
||||
@@ -323,11 +387,19 @@ class QuestionCreate(EventPermissionRequiredMixin, CreateView):
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def get_object(self):
|
||||
return None
|
||||
|
||||
@transaction.atomic()
|
||||
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.'))
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.event.question.added', user=self.request.user, data=dict(form.cleaned_data))
|
||||
self.save_formset(form.instance)
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class QuestionsForm(forms.Form):
|
||||
if a.question_id == q.id
|
||||
]
|
||||
if answers:
|
||||
initial = answers[0].answer
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
@@ -87,27 +87,45 @@ class QuestionsForm(forms.Form):
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
initial = (initial == "True")
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
initial=initial, widget=widget
|
||||
initial=initialbool, widget=widget
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
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:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
initial=initial
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
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
|
||||
if answers:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django import forms
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
|
||||
@@ -47,13 +48,33 @@ class QuestionsViewMixin:
|
||||
if v == '':
|
||||
field.answer.delete()
|
||||
else:
|
||||
field.answer.answer = v
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
elif v != '':
|
||||
QuestionAnswer.objects.create(
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
|
||||
question=field.question,
|
||||
answer=v
|
||||
)
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
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
|
||||
|
||||
@@ -74,6 +74,10 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.question-option-row {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.i18n-form-group input,
|
||||
.i18n-form-group textarea {
|
||||
.border-top-radius(0px);
|
||||
|
||||
Reference in New Issue
Block a user