Repository: comic/grand-challenge.org

Issue:
Remove questions in reader study
A question in a Reader Study cannot be removed, even if it has no answers yet. 

I think this should at least be possible when there are no answers yet. There are even use cases when I would want to remove a question after it had a few answers (of course, I would want to be warned, but then it should be possible).

An alternative solution, but probably a bit harder to implement, is to mark the Reader Study as 'Draft' or 'In Development', so that editors can play around with questions and dummy answers. Once the questions are confirmed and the reader study is actually sent to readers, it can be disallowed to remove questions.


Discussion:
This is fine, in the meantime you can ask any of the site admins (ask on slack) to delete questions.
Deletion really means deletion here. So, only do this if it will not result in any unexpected data loss. I don't want to implement a foot gun for reader study creators as the data are not simply recoverable. Shadow deletion will require logic changes in so many places, there is no appetite for that.

Reader study admins can already remove answers for each reader, they will need to do this first before removing the question. This covers this draft reader study use case. 

* Add a `DeleteView` for the `Question` model, with permission required `change_readerstudy`, override the `get_permission_object` to return the reader study associated with the question
* Look at other deletion templates for wording about how this is non-reversible. If related answers exist then point the user to the answer removal tables first and do not offer actual deletion. Check this in validation prior to deletion.
* Add a deletion button to the question update view. If answers for that question exist then disable the button, with an element title pointing them to answer removal first. This is similar to how Question fields become non-editable if answers exist, check the logic for that.

This is quite well defined already and needs to be covered by good tests both for permissions and logic.

Resolving patch:
diff --git a/app/grandchallenge/reader_studies/urls.py b/app/grandchallenge/reader_studies/urls.py
--- a/app/grandchallenge/reader_studies/urls.py
+++ b/app/grandchallenge/reader_studies/urls.py
@@ -6,6 +6,7 @@
     AddQuestionToReaderStudy,
     AnswersRemove,
     EditorsUpdate,
+    QuestionDelete,
     QuestionUpdate,
     ReaderStudyCopy,
     ReaderStudyCreate,
@@ -74,6 +75,11 @@
         QuestionUpdate.as_view(),
         name="question-update",
     ),
+    path(
+        "<slug>/questions/<pk>/delete/",
+        QuestionDelete.as_view(),
+        name="question-delete",
+    ),
     path(
         "<slug>/editors/update/",
         EditorsUpdate.as_view(),
diff --git a/app/grandchallenge/reader_studies/views.py b/app/grandchallenge/reader_studies/views.py
--- a/app/grandchallenge/reader_studies/views.py
+++ b/app/grandchallenge/reader_studies/views.py
@@ -14,6 +14,7 @@
 from django.http import (
     Http404,
     HttpResponse,
+    HttpResponseForbidden,
     HttpResponseRedirect,
     JsonResponse,
 )
@@ -949,3 +950,37 @@ def mine(self, request):
 
         serializer = self.get_serializer(queryset, many=True)
         return Response(serializer.data)
+
+
+class QuestionDelete(
+    LoginRequiredMixin, ObjectPermissionRequiredMixin, DeleteView
+):
+    model = Question
+
+    permission_required = (
+        f"{ReaderStudy._meta.app_label}.change_{ReaderStudy._meta.model_name}"
+    )
+    raise_exception = True
+
+    success_message = "Question was successfully deleted"
+
+    def get_permission_object(self):
+        return self.reader_study
+
+    @property
+    def reader_study(self):
+        return get_object_or_404(ReaderStudy, slug=self.kwargs["slug"])
+
+    def get_success_url(self):
+        return reverse(
+            "reader-studies:detail", kwargs={"slug": self.kwargs["slug"]}
+        )
+
+    def delete(self, request, *args, **kwargs):
+        question = self.get_object()
+        if question.is_fully_editable:
+            messages.success(self.request, self.success_message)
+            return super().delete(request, *args, **kwargs)
+        return HttpResponseForbidden(
+            reason="This question already has answers associated with it"
+        )


Test patch:
diff --git a/app/tests/reader_studies_tests/test_views.py b/app/tests/reader_studies_tests/test_views.py
--- a/app/tests/reader_studies_tests/test_views.py
+++ b/app/tests/reader_studies_tests/test_views.py
@@ -128,3 +128,92 @@ def test_answer_remove(client):
     assert Answer.objects.count() == 1
     assert Answer.objects.filter(creator=r1).count() == 0
     assert Answer.objects.filter(creator=r2).count() == 1
+
+
+@pytest.mark.django_db
+def test_question_delete(client):
+    rs = ReaderStudyFactory()
+    r1, editor = UserFactory(), UserFactory()
+    rs.add_reader(r1)
+    rs.add_editor(editor)
+    q = QuestionFactory(
+        reader_study=rs,
+        question_text="q1",
+        answer_type=Question.ANSWER_TYPE_BOOL,
+    )
+    assert Question.objects.count() == 1
+
+    response = get_view_for_user(
+        viewname="reader-studies:question-delete",
+        client=client,
+        method=client.post,
+        reverse_kwargs={"slug": rs.slug, "pk": q.pk},
+        follow=True,
+        user=r1,
+    )
+
+    assert response.status_code == 403
+
+    response = get_view_for_user(
+        viewname="reader-studies:question-delete",
+        client=client,
+        method=client.post,
+        reverse_kwargs={"slug": rs.slug, "pk": q.pk},
+        user=editor,
+    )
+
+    assert response.status_code == 302
+    assert Question.objects.count() == 0
+    assert str(rs) in response.url
+
+
+@pytest.mark.django_db
+def test_question_delete_disabled_for_questions_with_answers(client):
+    rs = ReaderStudyFactory()
+    r1, editor = UserFactory(), UserFactory()
+    rs.add_reader(r1)
+    rs.add_editor(editor)
+    q = QuestionFactory(
+        reader_study=rs,
+        question_text="q1",
+        answer_type=Question.ANSWER_TYPE_BOOL,
+    )
+    AnswerFactory(creator=r1, question=q, answer=True)
+
+    assert Answer.objects.count() == 1
+    assert Question.objects.count() == 1
+    assert not q.is_fully_editable
+
+    response = get_view_for_user(
+        viewname="reader-studies:question-delete",
+        client=client,
+        method=client.post,
+        reverse_kwargs={"slug": rs.slug, "pk": q.pk},
+        user=editor,
+    )
+
+    assert response.status_code == 403
+    assert Question.objects.count() == 1
+
+    # if answer is deleted, deletion of the question is possible again
+    get_view_for_user(
+        viewname="reader-studies:answers-remove",
+        client=client,
+        method=client.post,
+        reverse_kwargs={"slug": rs.slug},
+        data={"user": r1.id},
+        follow=True,
+        user=editor,
+    )
+
+    assert Answer.objects.count() == 0
+
+    response = get_view_for_user(
+        viewname="reader-studies:question-delete",
+        client=client,
+        method=client.post,
+        reverse_kwargs={"slug": rs.slug, "pk": q.pk},
+        user=editor,
+    )
+    assert response.status_code == 302
+    assert Question.objects.count() == 0