Repository: comic/grand-challenge.org

Issue:
Add option to remove reader study answers for a user
**Is your feature request related to a problem? Please describe.**
Currently it is possible to remove a reader from a reader study. However, doing so does not automatically remove the answers created by that user. In case the user is added to the study at a later point in time, they cannot complete the study again, because their previous answers are still intact.

**Describe the solution you'd like**
The best option would be to add a new button to remove all answers for a user in a reader study. That way the editors can decide whether or not they want to leave the existing data intact or remove all data associated with a removed reader. Adding the button also has the added benefit that it offers the editors the possibility to allow an existing reader to redo the study.

**Describe alternatives you've considered**
Another option would be to automatically remove all answers for a reader upon deletion. However, that could be unwanted in some cases. For instance, in the courses functionality that is currently under development (#1079), statistics for each case are also available. By automatically removing answers, valuable historical information could be omitted from these statistics. I therefore think it would be best to leave this decision to the editors of the reader study.


Discussion:
Who should we allow to perform this action? All reader study editors?
Yes, should we add `delete_answer` object permission to the reader study editors groups?

Resolving patch:
diff --git a/app/grandchallenge/reader_studies/forms.py b/app/grandchallenge/reader_studies/forms.py
--- a/app/grandchallenge/reader_studies/forms.py
+++ b/app/grandchallenge/reader_studies/forms.py
@@ -39,6 +39,7 @@
 from grandchallenge.core.layout import Formset
 from grandchallenge.core.widgets import JSONEditorWidget, MarkdownEditorWidget
 from grandchallenge.reader_studies.models import (
+    Answer,
     CASE_TEXT_SCHEMA,
     CategoricalOption,
     HANGING_LIST_SCHEMA,
@@ -348,6 +349,21 @@ def add_or_remove_user(self, *, reader_study):
         permission_request.save()
 
 
+class AnswersRemoveForm(Form):
+    user = ModelChoiceField(
+        queryset=get_user_model().objects.all().order_by("username"),
+        required=True,
+    )
+
+    def remove_answers(self, *, reader_study):
+        user = self.cleaned_data["user"]
+        Answer.objects.filter(
+            question__reader_study=reader_study,
+            creator=user,
+            is_ground_truth=False,
+        ).delete()
+
+
 class ReaderStudyPermissionRequestUpdateForm(PermissionRequestUpdateForm):
     class Meta(PermissionRequestUpdateForm.Meta):
         model = ReaderStudyPermissionRequest
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
@@ -4,6 +4,7 @@
     AddGroundTruthToReaderStudy,
     AddImagesToReaderStudy,
     AddQuestionToReaderStudy,
+    AnswersRemove,
     EditorsUpdate,
     QuestionUpdate,
     ReaderStudyCopy,
@@ -48,6 +49,11 @@
         name="statistics",
     ),
     path("<slug>/copy/", ReaderStudyCopy.as_view(), name="copy",),
+    path(
+        "<slug>/remove-answers/",
+        AnswersRemove.as_view(),
+        name="answers-remove",
+    ),
     path(
         "<slug>/ground-truth/add/",
         AddGroundTruthToReaderStudy.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
@@ -60,6 +60,7 @@
 )
 from grandchallenge.core.views import PermissionRequestUpdate
 from grandchallenge.reader_studies.forms import (
+    AnswersRemoveForm,
     CategoricalOptionFormSet,
     EditorsForm,
     GroundTruthForm,
@@ -192,6 +193,8 @@ def get_context_data(self, **kwargs):
         reader_remove_form.fields["action"].initial = ReadersForm.REMOVE
         editor_remove_form = EditorsForm()
         editor_remove_form.fields["action"].initial = EditorsForm.REMOVE
+        answers_remove_form = AnswersRemoveForm()
+
         context.update(
             {
                 "user_score": self.object.score_for_user(self.request.user),
@@ -199,6 +202,7 @@ def get_context_data(self, **kwargs):
                 * len(self.object.hanging_list),
                 "editor_remove_form": editor_remove_form,
                 "reader_remove_form": reader_remove_form,
+                "answers_remove_form": answers_remove_form,
                 "user_is_reader": self.object.is_reader(
                     user=self.request.user
                 ),
@@ -627,6 +631,36 @@ class ReadersUpdate(ReaderStudyUserGroupUpdateMixin):
     success_message = "Readers successfully updated"
 
 
+class AnswersRemove(
+    LoginRequiredMixin,
+    ObjectPermissionRequiredMixin,
+    SuccessMessageMixin,
+    FormView,
+):
+
+    template_name = "reader_studies/readerstudy_user_groups_form.html"
+    form_class = AnswersRemoveForm
+    success_message = "Answers removed"
+    permission_required = (
+        f"{ReaderStudy._meta.app_label}.change_{ReaderStudy._meta.model_name}"
+    )
+    raise_exception = True
+
+    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 form_valid(self, form):
+        form.remove_answers(reader_study=self.reader_study)
+        return super().form_valid(form)
+
+    def get_success_url(self):
+        return self.reader_study.get_absolute_url()
+
+
 class ReaderStudyPermissionRequestCreate(
     UserIsNotAnonMixin, SuccessMessageMixin, CreateView
 ):


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
@@ -5,6 +5,7 @@
 from grandchallenge.reader_studies.models import Answer, Question
 from tests.factories import ImageFactory, UserFactory
 from tests.reader_studies_tests.factories import (
+    AnswerFactory,
     CategoricalOptionFactory,
     QuestionFactory,
     ReaderStudyFactory,
@@ -80,3 +81,50 @@ def test_example_ground_truth(client, tmpdir):
             assert Answer.objects.filter(
                 images=image, question=question, is_ground_truth=True
             ).exists()
+
+
+@pytest.mark.django_db
+def test_answer_remove(client):
+    rs = ReaderStudyFactory()
+    r1, r2, editor = UserFactory(), UserFactory(), UserFactory()
+    rs.add_reader(r1)
+    rs.add_reader(r2)
+    rs.add_editor(editor)
+    q = QuestionFactory(
+        reader_study=rs,
+        question_text="q1",
+        answer_type=Question.ANSWER_TYPE_BOOL,
+    )
+    im = ImageFactory()
+    a1 = AnswerFactory(creator=r1, question=q, answer=True)
+    a1.images.set([im])
+    a2 = AnswerFactory(creator=r2, question=q, answer=True)
+    a2.images.set([im])
+    assert Answer.objects.count() == 2
+
+    response = 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=r1,
+    )
+
+    assert response.status_code == 403
+
+    response = 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 response.status_code == 200
+    assert Answer.objects.count() == 1
+    assert Answer.objects.filter(creator=r1).count() == 0
+    assert Answer.objects.filter(creator=r2).count() == 1