Repository: pretix/pretix

Issue:
Simplify reordering of questions
The reordering-process is quite annoying right now because it reloads the page every time I move a question up or down. Especially time consuming is it if you have a lot of questions and you want to move one from the very bottom to the top position.

Possible solutions I have in mind:
* make asynchronous requests and move the question within the web page, making complete reloads unnecessary
* add an number input field (maybe displayed only if a button is clicked) for redefining the order in one step for all questions
* drag-and-drop UI (inplace or in a new view)


Discussion:
I'm not sure which of the three solutions is the best, so I'm open for more input.

There is an additional challenge, though: If you have more than 25 questions, we currently paginate the list, which makes this even harder.
I'd be in favor of dropping pagination (unless you think this will ever be a problem with questions) and using drag'n'drop. UI with number inputs for ordering feel clunky and annoying to me (e.g. Goodreads To Read list interface).

Resolving patch:
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -164,6 +164,7 @@
             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'),
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py
--- a/src/pretix/control/views/item.py
+++ b/src/pretix/control/views/item.py
@@ -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 @@ def get_queryset(self):
         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


Test patch:
diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py
--- a/src/tests/control/test_items.py
+++ b/src/tests/control/test_items.py
@@ -174,7 +174,7 @@ def test_update(self):
     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 @@ def test_sort(self):
         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)
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -249,6 +249,9 @@ def test_wrong_event(perf_patch, client, env, url):
     # ("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),