Repository: comic/grand-challenge.org

Issue:
Leadersboards slow on sorting columns
~Not sure why, but when I view several challenge leaderboards the columns are non-responsive when trying to sort the data found within. Happens on MS Edge.~

Was a bit too quick in making conclusions. Take for instance: https://pi-cai.grand-challenge.org/evaluation/open-development-phase/leaderboard/. I tried sorting 'Created'. However, after being a bit more patient it seems it does sort but has a 5~8 second round-trip time.


Discussion:
Do you have a link? Which columns? Extra results columns are non-sortable btw.
Updated OP. I think at least a loading indication is in order.
For PI-CAI I am getting response times of 500-800ms for different column sorting when using Safari (home wifi). Slow but acceptable and within specs (<1s) for an async API view.

<img width="93" alt="Screenshot 2023-09-11 at 16 37 30" src="https://github.com/comic/grand-challenge.org/assets/12661555/06109aa9-af4b-4980-a501-d35c48f93918">

Originally noticed this from the RadboudUMC, but I admit from home it feels a bit quicker (also WiFi). Same PI-CAI leaderboard:

<img  width="93" src=https://github.com/comic/grand-challenge.org/assets/7871310/3eb67c41-05f4-4a9a-9145-8d5341717d9b />

Both MS Edge and Firefox are showing similar speeds. Initial retrieval of the page is ~180ms 'Waiting', which I assume is round-trip time.

Miriam also reports 2~3 seconds.
Huh, interesting, it's also 4x slower on Firefox than with Safari for me.
A fairly consistent
1.82s-1.87s on Chrome (tested 3x)
4.3-5.2s on firefox
1.65-1.89 on firefox in troubleshooting mode
( as per https://support.mozilla.org/en-US/kb/diagnose-firefox-issues-using-troubleshoot-mode#w_how-to-start-firefox-in-4troubleshoot-modesf5safe-modesf)

So this looks like it could be a firefox issue, to do extensions/themes or profiles - I've not pinned that down though
~~https://datatables.net/reference/option/deferRender might be the option we need - it's highly recommended by their documentation for larger datasets. I'm trying it to see if it makes any significant difference~~

ignore, looks like it's for clientside rendering, and we are doing serverside.
Looking a little more at this, we actually have well under the datatables suggested numbers for serverside processing -  I've looked at the largest challenges, and we seem to have max 500 entries on the leaderboard, datatables seems to comfortably handle clientside processing up to 5000 rows. 

Unless we're planning for challenges that are an order of magnitude larger than the current largest challenge, I think we might be able to switch to clientside, hammer the server less (so one request on page load, not one each time someone filters or clicks next page), and gain a bunch of performance here.

I did a quick test, and it doesn't seem to increase initial load time - is this a possible solution, or is this going in entirely the wrong direction?
No, then you'd move this 4.3-5.2s on firefox to the initial page load.
fair - so, tried a few things to improve this - I can reproduce it by creating a load of records in the dev site.

On firefox, creating a new profile reduced the time being taken from 4-5s to 1.8-2.2s - this seems to be a known firefox issue.

Hooking up a profiler to these shows that almost the entire time is spent waiting for a server response, on both firefox and IE - there's not a longer data rendering bit.
I'm not sure if there's a change from my end or on the server, but after a restart and windows update, edge/firefox/chrome are all pretty much identical - can anyone else confirm if they can still see this?

Was drawing a blank, so decided to check timings again, and they all now clock in at 1.6-1.8 for me.
Wifi from the hospital, on MS Edge:
![image](https://github.com/comic/grand-challenge.org/assets/7871310/90086603-896b-4314-8a7a-1a01ca6efe58)

Testing a local dev (only a single entry) returns <150 ms. Curious if this changes if there are more records. @james-milburn-crowe maybe you can see what the increase is when you ad more records locally?
Wonder if this:
- https://github.com/comic/grand-challenge.org/pull/3031

Fixes the render times here.
Shouldn't do, markdown not involved and it doesn't explain why there is no issue on Safari, points to being not a problem within the Django code at all.
Doing a `curl` GET with the URL is ~400 ms. If I selectively add the headers that MS Edge adds, the `X-Requested-With: XMLHttpRequest` causes a sudden increase to 7 ~ms~ seconds. Not sure if it is intentional or highlights a path.

@jmsmkn, just for sanity. I assume Safari also adds this header in the request since it's JS in origin?
> increase to 7ms

?

> I assume Safari also adds this header in the request since it's JS in origin?

Yes, it's there: X-Requested-With: XMLHttpRequest
Ups, meant to say 7s (shorted 7000ms but forgot to remove the mili).

Resolving patch:
diff --git a/app/grandchallenge/core/guardian.py b/app/grandchallenge/core/guardian.py
--- a/app/grandchallenge/core/guardian.py
+++ b/app/grandchallenge/core/guardian.py
@@ -35,7 +35,7 @@ class ObjectPermissionRequiredMixin(PermissionRequiredMixin):
     accept_global_perms = False
 
 
-def filter_by_permission(*, queryset, user, codename):
+def filter_by_permission(*, queryset, user, codename, accept_user_perms=True):
     """
     Optimised version of get_objects_for_user
 
@@ -59,18 +59,11 @@ def filter_by_permission(*, queryset, user, codename):
         # AnonymousUser does not work with filters
         user = get_anonymous_user()
 
-    dfk_user_model = get_user_obj_perms_model(queryset.model)
     dfk_group_model = get_group_obj_perms_model(queryset.model)
 
-    if dfk_user_model == UserObjectPermission:
-        raise RuntimeError("DFK user permissions not active for model")
-
     if dfk_group_model == GroupObjectPermission:
         raise RuntimeError("DFK group permissions not active for model")
 
-    user_related_query_name = (
-        dfk_user_model.content_object.field.related_query_name()
-    )
     group_related_query_name = (
         dfk_group_model.content_object.field.related_query_name()
     )
@@ -80,22 +73,32 @@ def filter_by_permission(*, queryset, user, codename):
         codename=codename,
     )
 
-    pks = (
-        queryset.filter(
-            **{
-                f"{user_related_query_name}__user": user,
-                f"{user_related_query_name}__permission": permission,
-            }
+    group_filter_kwargs = {
+        f"{group_related_query_name}__group__user": user,
+        f"{group_related_query_name}__permission": permission,
+    }
+
+    if accept_user_perms:
+        dfk_user_model = get_user_obj_perms_model(queryset.model)
+
+        if dfk_user_model == UserObjectPermission:
+            raise RuntimeError("DFK user permissions not active for model")
+
+        user_related_query_name = (
+            dfk_user_model.content_object.field.related_query_name()
         )
-        .union(
-            queryset.filter(
-                **{
-                    f"{group_related_query_name}__group__user": user,
-                    f"{group_related_query_name}__permission": permission,
-                }
-            )
+
+        user_filter_kwargs = {
+            f"{user_related_query_name}__user": user,
+            f"{user_related_query_name}__permission": permission,
+        }
+
+        pks = (
+            queryset.filter(**user_filter_kwargs)
+            .union(queryset.filter(**group_filter_kwargs))
+            .values_list("pk", flat=True)
         )
-        .values_list("pk", flat=True)
-    )
 
-    return queryset.filter(pk__in=pks)
+        return queryset.filter(pk__in=pks)
+    else:
+        return queryset.filter(**group_filter_kwargs)
diff --git a/app/grandchallenge/evaluation/models.py b/app/grandchallenge/evaluation/models.py
--- a/app/grandchallenge/evaluation/models.py
+++ b/app/grandchallenge/evaluation/models.py
@@ -1085,6 +1085,11 @@ def get_absolute_url(self):
 class EvaluationUserObjectPermission(UserObjectPermissionBase):
     content_object = models.ForeignKey(Evaluation, on_delete=models.CASCADE)
 
+    def save(self, *args, **kwargs):
+        raise RuntimeError(
+            "User permissions should not be assigned for this model"
+        )
+
 
 class EvaluationGroupObjectPermission(GroupObjectPermissionBase):
     content_object = models.ForeignKey(Evaluation, on_delete=models.CASCADE)
diff --git a/app/grandchallenge/evaluation/views/__init__.py b/app/grandchallenge/evaluation/views/__init__.py
--- a/app/grandchallenge/evaluation/views/__init__.py
+++ b/app/grandchallenge/evaluation/views/__init__.py
@@ -799,6 +799,7 @@ def get_queryset(self, *args, **kwargs):
             queryset=queryset,
             user=self.request.user,
             codename="view_evaluation",
+            accept_user_perms=False,
         )
 
     def filter_by_date(self, queryset):


Test patch:
diff --git a/app/tests/components_tests/test_models.py b/app/tests/components_tests/test_models.py
--- a/app/tests/components_tests/test_models.py
+++ b/app/tests/components_tests/test_models.py
@@ -339,25 +339,22 @@ def test_civ_updating(kind):
     civ = ComponentInterfaceValue.objects.last()
 
     # updating existing values does not work
+
+    if kind == InterfaceKindChoices.IMAGE:
+        image = ImageFactory()
+        civ.image = image
+    elif kind == InterfaceKindChoices.CSV:
+        file = ContentFile(b"Foo2", name="test2.csv")
+        civ.file = file
+    elif kind == InterfaceKindChoices.BOOL:
+        civ.value = False
+    elif kind == InterfaceKindChoices.STRING:
+        civ.value = "Foo"
+
+    civ.full_clean()
+
     with pytest.raises(ValidationError):
-        if kind == InterfaceKindChoices.IMAGE:
-            image = ImageFactory()
-            civ.image = image
-            civ.full_clean()
-            civ.save()
-        elif kind == InterfaceKindChoices.CSV:
-            file = ContentFile(b"Foo2", name="test2.csv")
-            civ.file = file
-            civ.full_clean()
-            civ.save()
-        elif kind == InterfaceKindChoices.BOOL:
-            civ.value = False
-            civ.full_clean()
-            civ.save()
-        elif kind == InterfaceKindChoices.STRING:
-            civ.value = "Foo"
-            civ.full_clean()
-            civ.save()
+        civ.save()
 
 
 @pytest.mark.django_db
diff --git a/app/tests/core_tests/test_guardian.py b/app/tests/core_tests/test_guardian.py
--- a/app/tests/core_tests/test_guardian.py
+++ b/app/tests/core_tests/test_guardian.py
@@ -16,9 +16,7 @@
     get_objects_for_group,
     get_objects_for_user,
 )
-from grandchallenge.evaluation.models import Evaluation
 from tests.algorithms_tests.factories import AlgorithmFactory
-from tests.evaluation_tests.factories import EvaluationFactory
 from tests.factories import GroupFactory, UserFactory
 
 
@@ -101,55 +99,135 @@ def get_permission_object(self):
 @pytest.mark.django_db
 def test_filter_by_permission():
     user = UserFactory()
-    queryset = Evaluation.objects.all()
-    evaluation = EvaluationFactory()
+    queryset = Algorithm.objects.all()
+    algorithm = AlgorithmFactory()
+    codename = "view_algorithm"
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # User does not have permission to view
     assert filtered_queryset.count() == 0
 
-    assign_perm("view_evaluation", user, evaluation)
+    assign_perm(codename, user, algorithm)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # Has user permission
     assert filtered_queryset.count() == 1
 
     group = GroupFactory()
     group.user_set.add(user)
-    assign_perm("view_evaluation", group, evaluation)
+    assign_perm(codename, group, algorithm)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # Has both user and group permission
     assert filtered_queryset.count() == 1
 
-    remove_perm("view_evaluation", user, evaluation)
+    remove_perm(codename, user, algorithm)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # Has group permission
     assert filtered_queryset.count() == 1
 
-    remove_perm("view_evaluation", group, evaluation)
+    remove_perm(codename, group, algorithm)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # Has no permission again
     assert filtered_queryset.count() == 0
 
-    assign_perm("view_evaluation", group, evaluation)
+    assign_perm(codename, group, algorithm)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # Has group permission again
     assert filtered_queryset.count() == 1
 
     group.user_set.remove(user)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
+    )
+    # Has no permission again
+    assert filtered_queryset.count() == 0
+
+
+@pytest.mark.django_db
+def test_filter_by_permission_no_user():
+    user = UserFactory()
+    queryset = Algorithm.objects.all()
+    algorithm = AlgorithmFactory()
+    codename = "view_algorithm"
+
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # User does not have permission to view
+    assert filtered_queryset.count() == 0
+
+    assign_perm(codename, user, algorithm)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # Has user permission, but they're not allowed
+    assert filtered_queryset.count() == 0
+
+    group = GroupFactory()
+    group.user_set.add(user)
+    assign_perm(codename, group, algorithm)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # Has both user and group permission
+    assert filtered_queryset.count() == 1
+
+    remove_perm(codename, user, algorithm)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # Has group permission
+    assert filtered_queryset.count() == 1
+
+    remove_perm(codename, group, algorithm)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # Has no permission again
+    assert filtered_queryset.count() == 0
+
+    assign_perm(codename, group, algorithm)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # Has group permission again
+    assert filtered_queryset.count() == 1
+
+    group.user_set.remove(user)
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
     )
     # Has no permission again
     assert filtered_queryset.count() == 0
@@ -158,56 +236,83 @@ def test_filter_by_permission():
 @pytest.mark.django_db
 def test_filter_by_permission_joined():
     user = UserFactory()
-    queryset = Evaluation.objects.all()
-    evaluation1, evaluation2 = EvaluationFactory(), EvaluationFactory()
+    queryset = Algorithm.objects.all()
+    algorithm1, algorithm2 = AlgorithmFactory(), AlgorithmFactory()
     group = GroupFactory()
     group.user_set.add(user)
+    codename = "view_algorithm"
 
-    assign_perm("view_evaluation", group, evaluation1)
-    assign_perm("view_evaluation", user, evaluation2)
+    assign_perm(codename, group, algorithm1)
+    assign_perm(codename, user, algorithm2)
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # User has permission to view both
     assert filtered_queryset.count() == 2
     assert {e.pk for e in filtered_queryset} == {
-        evaluation1.pk,
-        evaluation2.pk,
+        algorithm1.pk,
+        algorithm2.pk,
+    }
+
+    filtered_queryset = filter_by_permission(
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=False,
+    )
+    # User has permission to view both, but only group considered
+    assert filtered_queryset.count() == 1
+    assert {e.pk for e in filtered_queryset} == {
+        algorithm1.pk,
     }
 
 
 @pytest.mark.django_db
-def test_filter_by_global_permission():
+@pytest.mark.parametrize("accept_user_perms", (True, False))
+def test_filter_by_global_permission(accept_user_perms):
     user = UserFactory()
-    queryset = Evaluation.objects.all()
-    _ = EvaluationFactory()
+    queryset = Algorithm.objects.all()
+    _ = AlgorithmFactory()
+    codename = "view_algorithm"
+    perm = f"algorithms.{codename}"
 
-    assign_perm("evaluation.view_evaluation", user)
+    assign_perm(perm, user)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=accept_user_perms,
     )
     # User global permissions shouldn't count
     assert filtered_queryset.count() == 0
 
     group = GroupFactory()
     group.user_set.add(user)
-    assign_perm("evaluation.view_evaluation", group)
+    assign_perm(perm, group)
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=accept_user_perms,
     )
     # Group global permissions shouldn't count
     assert filtered_queryset.count() == 0
 
 
 @pytest.mark.django_db
-def test_filter_with_superuser():
+@pytest.mark.parametrize("accept_user_perms", (True, False))
+def test_filter_with_superuser(accept_user_perms):
     user = UserFactory(is_superuser=True)
-    queryset = Evaluation.objects.all()
-    _ = EvaluationFactory()
+    queryset = Algorithm.objects.all()
+    _ = AlgorithmFactory()
+    codename = "view_algorithm"
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset,
+        user=user,
+        codename=codename,
+        accept_user_perms=accept_user_perms,
     )
     # Superusers see everything
     assert filtered_queryset.count() == 1
@@ -216,18 +321,19 @@ def test_filter_with_superuser():
 @pytest.mark.django_db
 def test_works_with_anonymous_user():
     anon = get_anonymous_user()
-    queryset = Evaluation.objects.all()
-    evaluation = EvaluationFactory()
+    queryset = Algorithm.objects.all()
+    algorithm = AlgorithmFactory()
+    codename = "view_algorithm"
 
-    assign_perm("view_evaluation", anon, evaluation)
+    assign_perm(codename, anon, algorithm)
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=anon, codename="view_evaluation"
+        queryset=queryset, user=anon, codename=codename
     )
     assert filtered_queryset.count() == 1
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=AnonymousUser(), codename="view_evaluation"
+        queryset=queryset, user=AnonymousUser(), codename=codename
     )
     assert filtered_queryset.count() == 1
 
@@ -235,41 +341,43 @@ def test_works_with_anonymous_user():
 @pytest.mark.django_db
 def test_filter_ordering():
     user = UserFactory()
-    queryset = Evaluation.objects.all().order_by("created")
-    evaluation1 = EvaluationFactory()
-    evaluation2 = EvaluationFactory()
+    queryset = Algorithm.objects.all().order_by("created")
+    algorithm1, algorithm2 = AlgorithmFactory.create_batch(2)
+    codename = "view_algorithm"
 
-    evaluation1.created -= timedelta(minutes=10)
-    evaluation1.save()
+    algorithm1.created -= timedelta(minutes=10)
+    algorithm1.save()
 
     group = GroupFactory()
     group.user_set.add(user)
 
-    assign_perm("view_evaluation", group, evaluation1)
-    assign_perm("view_evaluation", user, evaluation2)
+    assign_perm(codename, group, algorithm1)
+    assign_perm(codename, user, algorithm2)
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset, user=user, codename=codename
     )
     # User has permission to view both but ordering must be maintained
     assert filtered_queryset.count() == 2
     assert [e.pk for e in filtered_queryset] == [
-        evaluation1.pk,
-        evaluation2.pk,
+        algorithm1.pk,
+        algorithm2.pk,
     ]
 
-    remove_perm("view_evaluation", group, evaluation1)
-    remove_perm("view_evaluation", user, evaluation2)
+    remove_perm(codename, group, algorithm1)
+    remove_perm(codename, user, algorithm2)
 
-    assign_perm("view_evaluation", group, evaluation2)
-    assign_perm("view_evaluation", user, evaluation1)
+    assign_perm(codename, group, algorithm2)
+    assign_perm(codename, user, algorithm1)
 
     filtered_queryset = filter_by_permission(
-        queryset=queryset, user=user, codename="view_evaluation"
+        queryset=queryset,
+        user=user,
+        codename=codename,
     )
     # User has permission to view both but ordering must be maintained
     assert filtered_queryset.count() == 2
     assert [e.pk for e in filtered_queryset] == [
-        evaluation1.pk,
-        evaluation2.pk,
+        algorithm1.pk,
+        algorithm2.pk,
     ]
diff --git a/app/tests/direct_messages_tests/test_signals.py b/app/tests/direct_messages_tests/test_signals.py
--- a/app/tests/direct_messages_tests/test_signals.py
+++ b/app/tests/direct_messages_tests/test_signals.py
@@ -60,7 +60,8 @@ def test_anon_not_participant():
 
     with pytest.raises(RuntimeError) as e:
         conversation.participants.add(get_anonymous_user())
-        assert str(e) == "The Anonymous User cannot be added to this group"
+
+    assert str(e.value) == "The Anonymous User cannot be added to this group"
 
 
 @pytest.mark.django_db
diff --git a/app/tests/evaluation_tests/test_views.py b/app/tests/evaluation_tests/test_views.py
--- a/app/tests/evaluation_tests/test_views.py
+++ b/app/tests/evaluation_tests/test_views.py
@@ -28,6 +28,7 @@
 )
 from tests.factories import (
     ChallengeFactory,
+    GroupFactory,
     UserFactory,
     WorkstationConfigFactory,
     WorkstationFactory,
@@ -160,8 +161,6 @@ def test_permission_required_views(self, client):
                 "view_submission",
                 e.submission,
             ),
-            ("update", {"pk": e.pk}, "change_evaluation", e),
-            ("detail", {"pk": e.pk}, "view_evaluation", e),
         ]:
             response = get_view_for_user(
                 client=client,
@@ -191,6 +190,53 @@ def test_permission_required_views(self, client):
 
             remove_perm(permission, u, obj)
 
+    def test_group_permission_required_views(self, client):
+        e = EvaluationFactory()
+        u = UserFactory()
+        g = GroupFactory()
+        g.user_set.add(u)
+        VerificationFactory(user=u, is_verified=True)
+
+        for view_name, kwargs, permission, obj in [
+            ("update", {"pk": e.pk}, "change_evaluation", e),
+            ("detail", {"pk": e.pk}, "view_evaluation", e),
+        ]:
+            response = get_view_for_user(
+                client=client,
+                viewname=f"evaluation:{view_name}",
+                reverse_kwargs={
+                    "challenge_short_name": e.submission.phase.challenge.short_name,
+                    **kwargs,
+                },
+                user=u,
+            )
+
+            assert response.status_code == 403
+
+            with pytest.raises(RuntimeError) as err:
+                assign_perm(permission, u, obj)
+
+            assert (
+                str(err.value)
+                == "User permissions should not be assigned for this model"
+            )
+
+            assign_perm(permission, g, obj)
+
+            response = get_view_for_user(
+                client=client,
+                viewname=f"evaluation:{view_name}",
+                reverse_kwargs={
+                    "challenge_short_name": e.submission.phase.challenge.short_name,
+                    **kwargs,
+                },
+                user=u,
+            )
+
+            assert response.status_code == 200
+
+            remove_perm(permission, g, obj)
+
     def test_permission_filtered_views(self, client):
         u = UserFactory()
         p = PhaseFactory()
@@ -208,6 +254,50 @@ def test_permission_filtered_views(self, client):
                 m,
             ),
             ("submission-list", {}, "view_submission", s),
+        ]:
+
+            assign_perm(permission, u, obj)
+
+            response = get_view_for_user(
+                client=client,
+                viewname=f"evaluation:{view_name}",
+                reverse_kwargs={
+                    "challenge_short_name": e.submission.phase.challenge.short_name,
+                    **kwargs,
+                },
+                user=u,
+            )
+
+            assert response.status_code == 200
+            assert obj in response.context[-1]["object_list"]
+
+            remove_perm(permission, u, obj)
+
+            response = get_view_for_user(
+                client=client,
+                viewname=f"evaluation:{view_name}",
+                reverse_kwargs={
+                    "challenge_short_name": e.submission.phase.challenge.short_name,
+                    **kwargs,
+                },
+                user=u,
+            )
+
+            assert response.status_code == 200
+            assert obj not in response.context[-1]["object_list"]
+
+    def test_group_only_permission_filtered_views(self, client):
+        u = UserFactory()
+        p = PhaseFactory()
+        m = MethodFactory(phase=p)
+        s = SubmissionFactory(phase=p, creator=u)
+        e = EvaluationFactory(
+            method=m, submission=s, rank=1, status=Evaluation.SUCCESS
+        )
+        g = GroupFactory()
+        g.user_set.add(u)
+
+        for view_name, kwargs, permission, obj in [
             ("list", {"slug": e.submission.phase.slug}, "view_evaluation", e),
             (
                 "leaderboard",
@@ -216,8 +306,15 @@ def test_permission_filtered_views(self, client):
                 e,
             ),
         ]:
+            with pytest.raises(RuntimeError) as err:
+                assign_perm(permission, u, obj)
 
-            assign_perm(permission, u, obj)
+            assert (
+                str(err.value)
+                == "User permissions should not be assigned for this model"
+            )
+
+            assign_perm(permission, g, obj)
 
             response = get_view_for_user(
                 client=client,
@@ -232,7 +329,7 @@ def test_permission_filtered_views(self, client):
             assert response.status_code == 200
             assert obj in response.context[-1]["object_list"]
 
-            remove_perm(permission, u, obj)
+            remove_perm(permission, g, obj)
 
             response = get_view_for_user(
                 client=client,
diff --git a/app/tests/hanging_protocols_tests/test_models.py b/app/tests/hanging_protocols_tests/test_models.py
--- a/app/tests/hanging_protocols_tests/test_models.py
+++ b/app/tests/hanging_protocols_tests/test_models.py
@@ -438,16 +438,20 @@ def test_view_content_validation():
         (model,),
         {"__module__": model.__module__},
     )
+
+    hp = model(view_content={"test": []})
+
     with pytest.raises(ValidationError):
-        hp = model(view_content={"test": []})
         hp.full_clean()
 
+    hp = model(view_content={"main": []})
+
     with pytest.raises(ValidationError):
-        hp = model(view_content={"main": []})
         hp.full_clean()
 
+    hp = model(view_content={"main": "test"})
+
     with pytest.raises(ValidationError):
-        hp = model(view_content={"main": "test"})
         hp.full_clean()
 
     hp = model(view_content={"main": ["test"]})
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
@@ -416,10 +416,11 @@ def test_validate_answer():
 
     with pytest.raises(ValidationError) as e:
         Answer.validate(creator=u, question=q, answer=True, display_set=ds)
-        assert (
-            e.value.message
-            == f"User {u} has already answered this question for this display set."
-        )
+
+    assert (
+        e.value.message
+        == f"User {u} has already answered this question for this display set."
+    )
 
     ds = DisplaySetFactory(reader_study=rs)
     assert (
@@ -682,20 +683,24 @@ def test_question_interface():
     q.clean()
     q.save()
     q.refresh_from_db()
+
     assert q.interface == ci_str
+
     ci_img = ComponentInterface.objects.filter(
         kind=InterfaceKindChoices.IMAGE
     ).first()
     q.interface = ci_img
+
     with pytest.raises(ValidationError) as e:
         q.clean()
-        q.save()
 
     assert e.value.message == (
         f"The interface {ci_img} is not allowed for this "
         f"question type ({AnswerType.SINGLE_LINE_TEXT})"
     )
+
     q.refresh_from_db()
+
     assert q.interface == ci_str
 
 
diff --git a/app/tests/serving_tests/test_views.py b/app/tests/serving_tests/test_views.py
--- a/app/tests/serving_tests/test_views.py
+++ b/app/tests/serving_tests/test_views.py
@@ -19,6 +19,7 @@
 )
 from tests.factories import (
     ChallengeRequestFactory,
+    GroupFactory,
     ImageFileFactory,
     UserFactory,
 )
@@ -179,7 +180,11 @@ def has_correct_access(user_allowed, user_denied, url):
     evaluation = EvaluationFactory()
     evaluation.output_interfaces.add(detection_interface)
     evaluation.outputs.add(output_civ)
-    assign_perm("view_evaluation", user1, evaluation)
+
+    group = GroupFactory()
+    group.user_set.add(user1)
+    assign_perm("view_evaluation", group, evaluation)
+
     # Evaluation inputs and outputs should always be denied
     assert (
         get_view_for_user(
diff --git a/app/tests/workstations_tests/test_templatetags.py b/app/tests/workstations_tests/test_templatetags.py
--- a/app/tests/workstations_tests/test_templatetags.py
+++ b/app/tests/workstations_tests/test_templatetags.py
@@ -233,9 +233,14 @@ def test_workstation_query_for_images(settings):
 def test_workstation_session_control_data():
     wk = WorkstationFactory()
     obj = ReaderStudyFactory()
+
     with pytest.raises(TypeError):
         workstation_session_control_data()
+
+    with pytest.raises(TypeError):
         workstation_session_control_data(workstation=wk)
+
+    with pytest.raises(TypeError):
         workstation_session_control_data(context_object=obj)
 
     data = workstation_session_control_data(