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 8090bd573f851a74cca442f4651c111b8750948d.

* 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

View File

@@ -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>

View File

@@ -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>

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'),

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

View File

@@ -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