Question list: Drop pagination, allow to mix ordering with system fields

This commit is contained in:
Raphael Michel
2020-06-23 13:05:54 +02:00
parent 868292f9b3
commit 2a5c24482e
8 changed files with 215 additions and 204 deletions

View File

@@ -244,8 +244,10 @@ class BaseQuestionsForm(forms.Form):
super().__init__(*args, **kwargs)
add_fields = {}
if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name_parts'] = NamePartsFormField(
add_fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required and not self.all_optional,
scheme=event.settings.name_scheme,
@@ -254,7 +256,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
)
if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField(
add_fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
@@ -265,14 +267,14 @@ class BaseQuestionsForm(forms.Form):
)
)
if item.admission and event.settings.attendee_company_asked:
self.fields['company'] = forms.CharField(
add_fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
self.fields['street'] = forms.CharField(
add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
widget=forms.Textarea(attrs={
@@ -282,7 +284,7 @@ class BaseQuestionsForm(forms.Form):
}),
initial=(cartpos.street if cartpos else orderpos.street),
)
self.fields['zipcode'] = forms.CharField(
add_fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
@@ -290,7 +292,7 @@ class BaseQuestionsForm(forms.Form):
'autocomplete': 'postal-code',
}),
)
self.fields['city'] = forms.CharField(
add_fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'),
initial=(cartpos.city if cartpos else orderpos.city),
@@ -299,7 +301,7 @@ class BaseQuestionsForm(forms.Form):
}),
)
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
self.fields['country'] = CountryField(
add_fields['country'] = CountryField(
countries=CachedCountries
).formfield(
required=event.settings.attendee_addresses_required and not self.all_optional,
@@ -324,7 +326,7 @@ class BaseQuestionsForm(forms.Form):
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
add_fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
@@ -332,7 +334,14 @@ class BaseQuestionsForm(forms.Form):
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
add_fields['state'].widget.is_required = True
field_positions = list(
[
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
for n in add_fields.keys()
]
)
for q in questions:
# Do we already have an answer? Provide it as the initial value
@@ -485,7 +494,12 @@ class BaseQuestionsForm(forms.Form):
field._required = q.required and not self.all_optional
field.required = False
self.fields['question_%s' % q.id] = field
add_fields['question_%s' % q.id] = field
field_positions.append(('question_%s' % q.id, q.position))
field_positions.sort(key=lambda e: e[1])
for fname, p in field_positions:
self.fields[fname] = add_fields[fname]
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data

View File

@@ -56,6 +56,10 @@ DEFAULTS = {
)
},
'system_question_order': {
'default': {},
'type': dict,
},
'attendee_names_asked': {
'default': 'True',
'type': bool,

View File

@@ -10,81 +10,89 @@
{% endblocktrans %}
</p>
{% csrf_token %}
{% if questions|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any questions yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new question" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for q in questions %}
<tr data-dnd-id="{{q.id}}">
<td><strong><a href="
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">{{ q.question }}</a></strong>
</td>
<td>
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for q in questions %}
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{% endif %}
{{ q.question }}
{% if q.pk %}
</a>
{% endif %}
</strong>
</td>
<td>
{% if q.pk %}
{{ q.get_type_display }}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk %}
<ul>
{% for item in q.items.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
</td>
<td>
<a href="{% url "control:event.items.questions.up" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 and is_paginated and not page_obj.has_previous %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
<a href="{% url "control:event.items.questions.down" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 and is_paginated and not page_obj.has_next %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
</td>
<td class="text-right flip">
{% else %}
<small>{% trans "All admission products" %}</small>
{% endif %}
</td>
<td class="dnd-container">
</td>
<td class="text-right flip">
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% else %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -180,9 +180,6 @@ urlpatterns = [
url(r'^questions/reorder$', item.reorder_questions, name='event.items.questions.reorder'),
url(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(),
name='event.items.questions.delete'),
url(r'^questions/(?P<question>\d+)/up$', item.question_move_up, name='event.items.questions.up'),
url(r'^questions/(?P<question>\d+)/down$', item.question_move_down,
name='event.items.questions.down'),
url(r'^questions/(?P<question>\d+)/$', item.QuestionView.as_view(),
name='event.items.questions.show'),
url(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),

View File

@@ -1,12 +1,12 @@
import json
from collections import OrderedDict
from collections import OrderedDict, namedtuple
from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import transaction
from django.db.models import Count, Exists, F, Max, OuterRef, Prefetch, Q
from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
@@ -279,7 +279,12 @@ def category_move_down(request, organizer, event, category):
event=request.event.slug)
class QuestionList(PaginationMixin, ListView):
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
class QuestionList(ListView):
model = Question
context_object_name = 'questions'
template_name = 'pretixcontrol/items/questions.html'
@@ -287,98 +292,125 @@ class QuestionList(PaginationMixin, ListView):
def get_queryset(self):
return self.request.event.questions.prefetch_related('items')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['questions'] = list(ctx['questions'])
if self.request.event.settings.attendee_names_asked:
ctx['questions'].append(
FakeQuestion(
id='attendee_name_parts',
question=_('Attendee name'),
position=self.request.event.settings.system_question_order.get(
'attendee_name_parts', 0
),
required=self.request.event.settings.attendee_names_required,
)
)
if self.request.event.settings.attendee_emails_asked:
ctx['questions'].append(
FakeQuestion(
id='attendee_email',
question=_('Attendee email'),
position=self.request.event.settings.system_question_order.get(
'attendee_email', 0
),
required=self.request.event.settings.attendee_emails_required,
)
)
if self.request.event.settings.attendee_emails_asked:
ctx['questions'].append(
FakeQuestion(
id='company',
question=_('Company'),
position=self.request.event.settings.system_question_order.get(
'company', 0
),
required=self.request.event.settings.attendee_company_required,
)
)
if self.request.event.settings.attendee_addresses_asked:
ctx['questions'].append(
FakeQuestion(
id='street',
question=_('Street'),
position=self.request.event.settings.system_question_order.get(
'street', 0
),
required=self.request.event.settings.attendee_addresses_required,
)
)
ctx['questions'].append(
FakeQuestion(
id='zipcode',
question=_('ZIP code'),
position=self.request.event.settings.system_question_order.get(
'zipcode', 0
),
required=self.request.event.settings.attendee_addresses_required,
)
)
ctx['questions'].append(
FakeQuestion(
id='city',
question=_('City'),
position=self.request.event.settings.system_question_order.get(
'city', 0
),
required=self.request.event.settings.attendee_addresses_required,
)
)
ctx['questions'].append(
FakeQuestion(
id='country',
question=_('Country'),
position=self.request.event.settings.system_question_order.get(
'country', 0
),
required=self.request.event.settings.attendee_addresses_required,
)
)
ctx['questions'].sort(key=lambda q: q.position)
return ctx
@transaction.atomic
@event_permission_required("can_change_items")
def reorder_questions(request, organizer, event):
try:
ids = [int(id) for id in json.loads(request.body.decode('utf-8'))['ids']]
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_questions = request.event.questions.filter(id__in=ids)
input_questions = request.event.questions.filter(id__in=[i for i in ids if i.isdigit()])
if input_questions.count() != len(ids):
if input_questions.count() != len([i for i in ids if i.isdigit()]):
raise Http404(_("Some of the provided question ids are invalid."))
first = input_questions.first()
last = input_questions.last()
original_lowest_score = (first.position, first.id)
original_highest_score = (last.position, last.id)
if input_questions.count() != request.event.questions.count():
raise Http404(_("Not all questions have been selected."))
if request.event.questions.filter(
Q(Q(position__gt=original_lowest_score[0])
| Q(Q(position=original_lowest_score[0]) & Q(pk__gt=original_lowest_score[1])))
&
Q(Q(position__lt=original_highest_score[0])
| Q(Q(position=original_highest_score[0]) & Q(pk__lt=original_highest_score[1])))
).exclude(id__in=ids).exists():
return HttpResponseBadRequest("ids need to be from a consecutive range of questions")
highest_position_on_previous_page = request.event.questions.filter(
Q(position__lt=original_lowest_score[0])
| Q(Q(position=original_lowest_score[0]) & Q(pk__lt=original_lowest_score[1]))
).aggregate(m=Max('position'))['m'] or 0
questions_on_later_pages = request.event.questions.filter(
Q(position__gt=original_highest_score[0])
| Q(Q(position=original_highest_score[0]) & Q(pk__gt=original_highest_score[1]))
)
ordered_questions = sorted(input_questions, key=lambda k: ids.index(k.pk))
for i, q in enumerate(ordered_questions + list(questions_on_later_pages)):
pos = highest_position_on_previous_page + 1 + i
for q in input_questions:
pos = ids.index(str(q.pk))
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
if s in ids:
system_question_order[s] = ids.index(s)
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
return HttpResponse()
def question_move(request, question, up=True):
"""
This is a helper function to avoid duplicating code in question_move_up and
question_move_down. It takes a question and a direction and then tries to bring
all items for this question in a new order.
"""
try:
question = request.event.questions.get(
id=question
)
except Question.DoesNotExist:
raise Http404(_("The selected question does not exist."))
questions = list(request.event.questions.order_by("position"))
index = questions.index(question)
if index != 0 and up:
questions[index - 1], questions[index] = questions[index], questions[index - 1]
elif index != len(questions) - 1 and not up:
questions[index + 1], questions[index] = questions[index], questions[index + 1]
for i, qt in enumerate(questions):
if qt.position != i:
qt.position = i
qt.save()
messages.success(request, _('The order of questions has been updated.'))
@event_permission_required("can_change_items")
def question_move_up(request, organizer, event, question):
question_move(request, question, up=True)
return redirect('control:event.items.questions',
organizer=request.event.organizer.slug,
event=request.event.slug)
@event_permission_required("can_change_items")
def question_move_down(request, organizer, event, question):
question_move(request, question, up=False)
return redirect('control:event.items.questions',
organizer=request.event.organizer.slug,
event=request.event.slug)
class QuestionDelete(EventPermissionRequiredMixin, DeleteView):
model = Question
template_name = 'pretixcontrol/items/question_delete.html'

View File

@@ -3,21 +3,10 @@ $(function () {
$("[data-dnd-url]").each(function(){
var container = $(this),
url = container.data("dnd-url"),
up = container.find("a:has(.fa-arrow-up)"),
handle = $('<span class="btn btn-default btn-sm dnd-sort-handle"><i class="fa fa-arrows"></i></span>');
function hideArrows(container){
var up = container.find("a:has(.fa-arrow-up)"),
firstUp = up.first(),
down = container.find("a:has(.fa-arrow-down)"),
lastDown = down.last();
up.not(firstUp).css("display","none");
down.not(lastDown).css("display","none");
firstUp.css("display","inline-block");
lastDown.css("display","inline-block");
}
up.after(handle);
hideArrows(container);
console.log(container, container.find(".dnd-container"));
container.find(".dnd-container").append(handle);
Sortable.create(container.get(0), {
handle: ".dnd-sort-handle",
@@ -25,8 +14,6 @@ $(function () {
var container = $(evt.to),
ids = container.find("[data-dnd-id]").toArray().map(function (e) { return e.dataset.dndId; });
hideArrows(container);
$.ajax(
{
'type': 'POST',

View File

@@ -171,35 +171,6 @@ class QuestionsTest(ItemFormTest):
self.assertTrue(c.required)
assert str(Question.objects.get(id=c.id).question) == 'How old are you?'
def test_sort(self):
with scopes_disabled():
q1 = Question.objects.create(event=self.event1, question="Vegetarian?", type="N", required=True, position=0)
q2 = Question.objects.create(event=self.event1, question="Food allergies?", position=1)
doc = self.get_doc('/control/event/%s/%s/questions/' % (self.orga1.slug, self.event1.slug))
self.assertIn("Vegetarian?", doc.select("table > tbody > tr")[0].text)
self.assertIn("Food allergies?", doc.select("table > tbody > tr")[1].text)
self.client.get('/control/event/%s/%s/questions/%s/down' % (self.orga1.slug, self.event1.slug, q1.id))
doc = self.get_doc('/control/event/%s/%s/questions/' % (self.orga1.slug, self.event1.slug))
self.assertIn("Vegetarian?", doc.select("table > tbody > tr")[1].text)
self.assertIn("Food allergies?", doc.select("table > tbody > tr")[0].text)
self.client.get('/control/event/%s/%s/questions/%s/up' % (self.orga1.slug, self.event1.slug, q1.id))
doc = self.get_doc('/control/event/%s/%s/questions/' % (self.orga1.slug, self.event1.slug))
self.assertIn("Vegetarian?", doc.select("table > tbody > tr")[0].text)
self.assertIn("Food allergies?", doc.select("table > tbody > tr")[1].text)
self.client.post(
'/control/event/%s/%s/questions/reorder' % (self.orga1.slug, self.event1.slug),
{
"ids": [q2.id, q1.id]
},
content_type='application/json'
)
doc = self.get_doc('/control/event/%s/%s/questions/' % (self.orga1.slug, self.event1.slug))
self.assertIn("Vegetarian?", doc.select("table > tbody > tr")[1].text)
self.assertIn("Food allergies?", doc.select("table > tbody > tr")[0].text)
def test_delete(self):
with scopes_disabled():
c = Question.objects.create(event=self.event1, question="What is your shoe size?", type="N", required=True)

View File

@@ -260,8 +260,6 @@ event_permission_urls = [
# ("can_change_items", "questions/", 200),
("can_change_items", "questions/2/", 404),
("can_change_items", "questions/2/delete", 404),
("can_change_items", "questions/2/up", 404),
("can_change_items", "questions/2/down", 404),
("can_change_items", "questions/reorder", 400),
("can_change_items", "questions/add", 200),
# ("can_change_items", "quotas/", 200),