Repository: comic/grand-challenge.org

Issue:
Public archives never filtered in list view
As the `view_archive` permission is used to allow downloading of the archive the queryset for the list view is not constructed correctly. Add a new `download_archive` permission, and assign the `view_archive` permission to the reg and anon group as we do in algorithms and reader studies. Would also be good to add similar permissions to challenges.

See #1573


Discussion:

Resolving patch:
diff --git a/app/grandchallenge/archives/migrations/0003_auto_20201215_0931.py b/app/grandchallenge/archives/migrations/0003_auto_20201215_0931.py
new file mode 100644
--- /dev/null
+++ b/app/grandchallenge/archives/migrations/0003_auto_20201215_0931.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.1.1 on 2020-12-15 09:31
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("archives", "0002_auto_20201214_0939"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="archive",
+            options={
+                "ordering": ("created",),
+                "permissions": [
+                    (
+                        "use_archive",
+                        "Can use the objects in the archive as inputs to algorithms, reader studies and challenges.",
+                    ),
+                    ("upload_archive", "Can upload to archive"),
+                ],
+            },
+        ),
+    ]
diff --git a/app/grandchallenge/archives/models.py b/app/grandchallenge/archives/models.py
--- a/app/grandchallenge/archives/models.py
+++ b/app/grandchallenge/archives/models.py
@@ -1,8 +1,9 @@
+from django.conf import settings
 from django.contrib.auth.models import Group
 from django.db import models, transaction
 from django.db.models import Count
 from django_extensions.db.models import TitleSlugDescriptionModel
-from guardian.shortcuts import assign_perm
+from guardian.shortcuts import assign_perm, remove_perm
 
 from grandchallenge.algorithms.models import Algorithm
 from grandchallenge.anatomy.models import BodyStructure
@@ -79,7 +80,16 @@ class Archive(UUIDModel, TitleSlugDescriptionModel):
 
     class Meta(UUIDModel.Meta, TitleSlugDescriptionModel.Meta):
         ordering = ("created",)
-        permissions = [("upload_archive", "Can upload to archive")]
+        permissions = [
+            (
+                "use_archive",
+                (
+                    "Can use the objects in the archive as inputs to "
+                    "algorithms, reader studies and challenges."
+                ),
+            ),
+            ("upload_archive", "Can upload to archive"),
+        ]
 
     def __str__(self):
         return f"<{self.__class__.__name__} {self.title}>"
@@ -117,6 +127,12 @@ def assign_permissions(self):
             f"view_{self._meta.model_name}", self.uploaders_group, self
         )
         assign_perm(f"view_{self._meta.model_name}", self.users_group, self)
+
+        # Allow the editors, uploaders and users group to use the archive
+        assign_perm(f"use_{self._meta.model_name}", self.editors_group, self)
+        assign_perm(f"use_{self._meta.model_name}", self.uploaders_group, self)
+        assign_perm(f"use_{self._meta.model_name}", self.users_group, self)
+
         # Allow editors and uploaders to upload to this
         assign_perm(
             f"upload_{self._meta.model_name}", self.editors_group, self
@@ -129,7 +145,14 @@ def assign_permissions(self):
             f"change_{self._meta.model_name}", self.editors_group, self
         )
 
-        # TODO: Handle public permissions
+        reg_and_anon = Group.objects.get(
+            name=settings.REGISTERED_AND_ANON_USERS_GROUP_NAME
+        )
+
+        if self.public:
+            assign_perm(f"view_{self._meta.model_name}", reg_and_anon, self)
+        else:
+            remove_perm(f"view_{self._meta.model_name}", reg_and_anon, self)
 
     def delete(self, *args, **kwargs):
         """
diff --git a/app/grandchallenge/archives/views.py b/app/grandchallenge/archives/views.py
--- a/app/grandchallenge/archives/views.py
+++ b/app/grandchallenge/archives/views.py
@@ -29,6 +29,7 @@
     PermissionListMixin,
     PermissionRequiredMixin as ObjectPermissionRequiredMixin,
 )
+from guardian.shortcuts import get_objects_for_user
 from rest_framework.settings import api_settings
 from rest_framework.viewsets import ReadOnlyModelViewSet
 from rest_framework_guardian.filters import ObjectPermissionsFilter
@@ -69,15 +70,6 @@ class ArchiveList(PermissionListMixin, FilterMixin, ListView):
     ordering = "-created"
     filter_class = ArchiveFilter
 
-    def get_queryset(self, *args, **kwargs):
-        queryset = super().get_queryset()
-        queryset = (
-            # Ensure both qs are distinct so that they can be combined
-            queryset.distinct()
-            | Archive.objects.filter(public=True).distinct()
-        ).distinct()
-        return queryset
-
     def get_context_data(self, *args, **kwargs):
         context = super().get_context_data(*args, **kwargs)
 
@@ -119,7 +111,7 @@ class ArchiveDetail(
 ):
     model = Archive
     permission_required = (
-        f"{model._meta.app_label}.view_{model._meta.model_name}"
+        f"{model._meta.app_label}.use_{model._meta.model_name}"
     )
     raise_exception = True
 
@@ -191,15 +183,9 @@ class ArchiveUsersAutocomplete(
     LoginRequiredMixin, UserPassesTestMixin, autocomplete.Select2QuerySetView
 ):
     def test_func(self):
-        group_pks = (
-            Archive.objects.all()
-            .select_related("editors_group")
-            .values_list("editors_group__pk", flat=True)
-        )
-        return (
-            self.request.user.is_superuser
-            or self.request.user.groups.filter(pk__in=group_pks).exists()
-        )
+        return get_objects_for_user(
+            user=self.request.user, perms="change_archive", klass=Archive
+        ).exists()
 
     def get_queryset(self):
         qs = (
@@ -395,7 +381,7 @@ class ArchiveCasesList(
 ):
     model = Image
     permission_required = (
-        f"{Archive._meta.app_label}.view_{Archive._meta.model_name}"
+        f"{Archive._meta.app_label}.use_{Archive._meta.model_name}"
     )
     raise_exception = True
     template_name = "archives/archive_cases_list.html"
@@ -448,7 +434,7 @@ class ArchiveCasesToReaderStudyUpdate(
 ):
     form_class = ArchiveCasesToReaderStudyForm
     permission_required = (
-        f"{Archive._meta.app_label}.view_{Archive._meta.model_name}"
+        f"{Archive._meta.app_label}.use_{Archive._meta.model_name}"
     )
     raise_exception = True
     template_name = "archives/archive_cases_to_reader_study_form.html"


Test patch:
diff --git a/app/tests/archives_tests/test_permissions.py b/app/tests/archives_tests/test_permissions.py
new file mode 100644
--- /dev/null
+++ b/app/tests/archives_tests/test_permissions.py
@@ -0,0 +1,58 @@
+import pytest
+from django.conf import settings
+from django.contrib.auth.models import Group
+from guardian.shortcuts import get_perms, get_users_with_perms
+
+from grandchallenge.archives.models import Archive
+from tests.archives_tests.factories import ArchiveFactory
+from tests.evaluation_tests.test_permissions import get_groups_with_set_perms
+
+
+@pytest.mark.django_db
+class TestArchivePermissions:
+    @pytest.mark.parametrize("public", (True, False))
+    def test_archive_permissions(self, public):
+        a: Archive = ArchiveFactory(public=public)
+
+        expected_perms = {
+            a.editors_group: {
+                "view_archive",
+                "use_archive",
+                "upload_archive",
+                "change_archive",
+            },
+            a.uploaders_group: {
+                "view_archive",
+                "use_archive",
+                "upload_archive",
+            },
+            a.users_group: {"view_archive", "use_archive"},
+        }
+
+        if public:
+            reg_and_anon = Group.objects.get(
+                name=settings.REGISTERED_AND_ANON_USERS_GROUP_NAME
+            )
+            expected_perms[reg_and_anon] = {"view_archive"}
+
+        assert get_groups_with_set_perms(a) == expected_perms
+        assert get_users_with_perms(a, with_group_users=False).count() == 0
+
+    @pytest.mark.django_db
+    def test_visible_to_public_group_permissions(self):
+        g_reg_anon = Group.objects.get(
+            name=settings.REGISTERED_AND_ANON_USERS_GROUP_NAME
+        )
+        a = ArchiveFactory()
+
+        assert "view_archive" not in get_perms(g_reg_anon, a)
+
+        a.public = True
+        a.save()
+
+        assert "view_archive" in get_perms(g_reg_anon, a)
+
+        a.public = False
+        a.save()
+
+        assert "view_archive" not in get_perms(g_reg_anon, a)
diff --git a/app/tests/archives_tests/test_views.py b/app/tests/archives_tests/test_views.py
new file mode 100644
--- /dev/null
+++ b/app/tests/archives_tests/test_views.py
@@ -0,0 +1,104 @@
+import pytest
+from guardian.shortcuts import assign_perm, remove_perm
+
+from grandchallenge.subdomains.utils import reverse
+from tests.archives_tests.factories import (
+    ArchiveFactory,
+    ArchivePermissionRequestFactory,
+)
+from tests.factories import UserFactory
+from tests.utils import get_view_for_user
+
+
+@pytest.mark.django_db
+class TestObjectPermissionRequiredViews:
+    def test_permission_required_views(self, client):
+        a = ArchiveFactory()
+        u = UserFactory()
+        p = ArchivePermissionRequestFactory(archive=a)
+
+        for view_name, kwargs, permission, obj, redirect in [
+            ("users-autocomplete", {}, "change_archive", a, None,),
+            ("create", {}, "archives.add_archive", None, None),
+            (
+                "detail",
+                {"slug": a.slug},
+                "use_archive",
+                a,
+                reverse(
+                    "archives:permission-request-create",
+                    kwargs={"slug": a.slug},
+                ),
+            ),
+            ("update", {"slug": a.slug}, "change_archive", a, None,),
+            ("editors-update", {"slug": a.slug}, "change_archive", a, None,),
+            ("uploaders-update", {"slug": a.slug}, "change_archive", a, None,),
+            ("users-update", {"slug": a.slug}, "change_archive", a, None,),
+            (
+                "permission-request-update",
+                {"slug": a.slug, "pk": p.pk},
+                "change_archive",
+                a,
+                None,
+            ),
+            ("cases-list", {"slug": a.slug}, "use_archive", a, None,),
+            ("cases-create", {"slug": a.slug}, "upload_archive", a, None,),
+            (
+                "cases-reader-study-update",
+                {"slug": a.slug},
+                "use_archive",
+                a,
+                None,
+            ),
+        ]:
+
+            def _get_view():
+                return get_view_for_user(
+                    client=client,
+                    viewname=f"archives:{view_name}",
+                    reverse_kwargs=kwargs,
+                    user=u,
+                )
+
+            response = _get_view()
+            if redirect is not None:
+                assert response.status_code == 302
+                assert response.url == redirect
+            else:
+                assert response.status_code == 403
+
+            assign_perm(permission, u, obj)
+
+            response = _get_view()
+            assert response.status_code == 200
+
+            remove_perm(permission, u, obj)
+
+    def test_permission_required_list_views(self, client):
+        a = ArchiveFactory()
+        u = UserFactory()
+
+        for view_name, kwargs, permission, objs in [
+            ("list", {}, "view_archive", {a}),
+        ]:
+
+            def _get_view():
+                return get_view_for_user(
+                    client=client,
+                    viewname=f"archives:{view_name}",
+                    reverse_kwargs=kwargs,
+                    user=u,
+                )
+
+            response = _get_view()
+            assert response.status_code == 200
+            assert set() == {*response.context[-1]["object_list"]}
+
+            assign_perm(permission, u, list(objs))
+
+            response = _get_view()
+            assert response.status_code == 200
+            assert objs == {*response.context[-1]["object_list"]}
+
+            for obj in objs:
+                remove_perm(permission, u, obj)