Reordering questions with drag'n'drop (#1433)

* Reordering questions with drag'n'drop

* Add permission check

* Test permissions for question reordering

* Handle malformed requests for question reordering

* Show first up arrow and last down arrow

* Provide page offset

* Revert "Provide page offset"

This reverts commit 8090bd573f.

* Reorder questions endpoint with pagination support

* Rudimentary test for reordering endpoint

* Make reordering questions atomic

* cache questions

* Properly support pagination for reorder_questions

* appease linter

* Fix test
This commit is contained in:
Sohalt
2019-10-14 10:54:20 +02:00
committed by Raphael Michel
parent f10d1bd236
commit 91b586ce08
9 changed files with 3823 additions and 5 deletions
@@ -43,6 +43,7 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/dragndroplist.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/orderchange.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/typeahead.js" %}"></script>
@@ -51,6 +52,7 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/tabs.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "sortable/Sortable.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
@@ -39,9 +39,9 @@
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for q in questions %}
<tr>
<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>
+1
View File
@@ -165,6 +165,7 @@ urlpatterns = [
name='event.items.categories.edit'),
url(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
url(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
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'),
+53 -2
View File
@@ -1,13 +1,16 @@
import json
from collections import OrderedDict
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, F, Prefetch, Q
from django.db.models import Count, F, Max, Prefetch, Q
from django.forms.models import inlineformset_factory
from django.http import Http404, HttpResponseRedirect
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
)
from django.shortcuts import redirect
from django.urls import resolve, reverse
from django.utils.functional import cached_property
@@ -283,6 +286,54 @@ class QuestionList(PaginationMixin, ListView):
return self.request.event.questions.prefetch_related('items')
@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']]
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_questions = request.event.questions.filter(id__in=ids)
if input_questions.count() != len(ids):
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 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
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
return HttpResponse()
def question_move(request, question, up=True):
"""
This is a helper function to avoid duplicating code in question_move_up and
@@ -0,0 +1,45 @@
/*global $, Sortable*/
$(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);
Sortable.create(container.get(0), {
handle: ".dnd-sort-handle",
onSort: function(evt){
var container = $(evt.to),
ids = container.find("[data-dnd-id]").toArray().map(e => e.dataset.dndId);
hideArrows(container);
$.ajax(
{
'type': 'POST',
'url': url,
'headers': {'X-CSRFToken': $("input[name=csrfmiddlewaretoken]").val()},
'data': JSON.stringify({
ids: ids
}),
'contentType': "application/json",
'timeout': 30000
}
);
}
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -174,7 +174,7 @@ class QuestionsTest(ItemFormTest):
def test_sort(self):
with scopes_disabled():
q1 = Question.objects.create(event=self.event1, question="Vegetarian?", type="N", required=True, position=0)
Question.objects.create(event=self.event1, question="Food allergies?", position=1)
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)
@@ -189,6 +189,17 @@ class QuestionsTest(ItemFormTest):
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)
+3
View File
@@ -249,6 +249,9 @@ 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),
("can_change_items", "quotas/2/change", 404),