Repository: comic/grand-challenge.org

Issue:
Add the users progress in a reader study
It would be nice for both a reader to see their progress and an admin to see the progress of each reader on the reader study detail page.


Discussion:

Resolving patch:
diff --git a/app/grandchallenge/reader_studies/models.py b/app/grandchallenge/reader_studies/models.py
--- a/app/grandchallenge/reader_studies/models.py
+++ b/app/grandchallenge/reader_studies/models.py
@@ -5,12 +5,14 @@
 from django.contrib.postgres.fields import JSONField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models
+from django.db.models import Count
 from django.db.models.signals import post_delete
 from django.dispatch import receiver
 from django_extensions.db.models import TitleSlugDescriptionModel
 from guardian.shortcuts import assign_perm, get_objects_for_group, remove_perm
 from numpy.random.mtrand import RandomState
 
+from grandchallenge.cases.models import Image
 from grandchallenge.challenges.models import get_logo_path
 from grandchallenge.core.models import UUIDModel
 from grandchallenge.core.validators import JSONSchemaValidator
@@ -269,6 +271,58 @@ def generate_hanging_list(self):
         self.hanging_list = [{"main": name} for name in image_names]
         self.save()
 
+    def get_progress_for_user(self, user):
+        if not self.is_valid or not self.hanging_list:
+            return
+        answerable_questions = self.questions.exclude(
+            answer_type=Question.ANSWER_TYPE_HEADING
+        )
+        answerable_question_count = answerable_questions.count()
+        hanging_list_count = len(self.hanging_list)
+
+        expected = hanging_list_count * answerable_question_count
+        answers = Answer.objects.filter(
+            question__in=answerable_questions, creator_id=user.id
+        ).distinct()
+        answer_count = answers.count()
+        # There are unanswered questions
+        if answer_count % answerable_question_count != 0:
+            # Group the answers by images and filter out the images that
+            # have an inadequate amount of answers
+            unanswered_images = (
+                answers.order_by("images__name")
+                .values("images__name")
+                .annotate(answer_count=Count("images__name"))
+                .filter(answer_count__lt=answerable_question_count)
+            )
+            image_names = set(
+                unanswered_images.values_list("images__name", flat=True)
+            ).union(
+                set(
+                    Image.objects.filter(
+                        readerstudies=self, answers__isnull=True
+                    )
+                    .distinct()
+                    .values_list("name", flat=True)
+                )
+            )
+            # Determine which hangings have images with unanswered questions
+            hanging_list = [set(x.values()) for x in self.hanging_list]
+            completed_hangings = [
+                x for x in hanging_list if len(x - image_names) == len(x)
+            ]
+            completed_hangings = len(completed_hangings)
+        else:
+            completed_hangings = answer_count / answerable_question_count
+
+        hangings = completed_hangings / hanging_list_count * 100
+        questions = answer_count / expected * 100
+        return {
+            "questions": questions,
+            "hangings": hangings,
+            "diff": questions - hangings,
+        }
+
 
 @receiver(post_delete, sender=ReaderStudy)
 def delete_reader_study_groups_hook(*_, instance: ReaderStudy, using, **__):
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
@@ -23,6 +23,7 @@
     PermissionListMixin,
     PermissionRequiredMixin as ObjectPermissionRequiredMixin,
 )
+from guardian.shortcuts import get_perms
 from rest_framework.decorators import action
 from rest_framework.mixins import (
     CreateModelMixin,
@@ -95,6 +96,21 @@ class ReaderStudyDetail(
 
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
+        change_perm = f"change_{ReaderStudy._meta.model_name}"
+        if change_perm in get_perms(self.request.user, self.object):
+            readers = [
+                {
+                    "obj": reader,
+                    "progress": self.object.get_progress_for_user(reader),
+                }
+                for reader in self.object.readers_group.user_set.all()
+            ]
+            context.update({"readers": readers})
+        else:
+            user_progress = self.object.get_progress_for_user(
+                self.request.user
+            )
+            context.update({"progress": user_progress})
         context.update(
             {"user_is_reader": self.object.is_reader(user=self.request.user)}
         )


Test patch:
diff --git a/app/tests/reader_studies_tests/test_models.py b/app/tests/reader_studies_tests/test_models.py
--- a/app/tests/reader_studies_tests/test_models.py
+++ b/app/tests/reader_studies_tests/test_models.py
@@ -2,7 +2,7 @@
 from django.core.exceptions import ObjectDoesNotExist
 
 from grandchallenge.reader_studies.models import ReaderStudy
-from tests.factories import ImageFactory
+from tests.factories import ImageFactory, UserFactory
 from tests.reader_studies_tests.factories import (
     AnswerFactory,
     QuestionFactory,
@@ -84,3 +84,52 @@ def test_generate_hanging_list():
         {"main": "im1"},
         {"main": "im2"},
     ]
+
+
+@pytest.mark.django_db
+def test_progress_for_user():
+    rs = ReaderStudyFactory()
+    im1, im2 = ImageFactory(name="im1"), ImageFactory(name="im2")
+    q1, q2, q3 = [
+        QuestionFactory(reader_study=rs),
+        QuestionFactory(reader_study=rs),
+        QuestionFactory(reader_study=rs),
+    ]
+
+    reader = UserFactory()
+    rs.add_reader(reader)
+
+    question_perc = 100 / 6
+
+    assert rs.get_progress_for_user(reader) is None
+
+    rs.images.set([im1, im2])
+    rs.hanging_list = [{"main": im1.name}, {"main": im2.name}]
+    rs.save()
+
+    progress = rs.get_progress_for_user(reader)
+    assert progress["hangings"] == 0
+    assert progress["questions"] == 0
+
+    a11 = AnswerFactory(question=q1, answer="foo", creator=reader)
+    a11.images.add(im1)
+
+    progress = rs.get_progress_for_user(reader)
+    assert progress["hangings"] == 0
+    assert progress["questions"] == pytest.approx(question_perc)
+
+    a21 = AnswerFactory(question=q1, answer="foo", creator=reader)
+    a21.images.add(im2)
+
+    progress = rs.get_progress_for_user(reader)
+    assert progress["hangings"] == 0
+    assert progress["questions"] == pytest.approx(question_perc * 2)
+
+    a12 = AnswerFactory(question=q2, answer="foo", creator=reader)
+    a12.images.add(im1)
+    a13 = AnswerFactory(question=q3, answer="foo", creator=reader)
+    a13.images.add(im1)
+
+    progress = rs.get_progress_for_user(reader)
+    assert progress["hangings"] == 50
+    assert progress["questions"] == pytest.approx(question_perc * 4)