Skip to content

Commit f9f8d74

Browse files
authored
Update image permission assignment through algorithm jobs (#4386)
Avoids the use of the slow `update_viewer_groups_permissions` method by assigning the `view_image` permissions directly. The downside of this is that the permissions will need to be kept in sync in both `signals.py` and `_get_expected_job_viewer_groups` but we have good test coverage for this. Closes DIAGNijmegen/rse-grand-challenge-admin#663
1 parent dac9c0a commit f9f8d74

4 files changed

Lines changed: 109 additions & 48 deletions

File tree

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
from django.db.models.signals import m2m_changed
1+
from django.db.models.signals import m2m_changed, pre_delete
22
from django.dispatch import receiver
33
from guardian.shortcuts import assign_perm, remove_perm
44

55
from grandchallenge.algorithms.models import Job
6-
from grandchallenge.components.models import ComponentInterfaceValue
6+
from grandchallenge.cases.models import Image
77

88

99
@receiver(m2m_changed, sender=Job.inputs.through)
1010
@receiver(m2m_changed, sender=Job.outputs.through)
11-
def update_input_image_permissions(
11+
def update_view_image_permissions_on_job_io_change( # noqa:C901
1212
sender, instance, action, reverse, model, pk_set, **_
1313
):
1414
"""
@@ -21,56 +21,67 @@ def update_input_image_permissions(
2121
return
2222

2323
if sender._meta.label_lower == "algorithms.job_inputs":
24-
forward_lookup = "inputs"
2524
reverse_lookup = "algorithms_jobs_as_input"
2625
elif sender._meta.label_lower == "algorithms.job_outputs":
27-
forward_lookup = "outputs"
2826
reverse_lookup = "algorithms_jobs_as_output"
2927
else:
3028
raise RuntimeError("m2m is only valid for Job inputs and outputs.")
3129

3230
if reverse:
33-
component_interface_values = ComponentInterfaceValue.objects.filter(
34-
pk=instance.pk, image__isnull=False
35-
)
31+
images = Image.objects.filter(componentinterfacevalue__pk=instance.pk)
3632
if pk_set is None:
3733
# When using a _clear action, pk_set is None
3834
# https://docs.djangoproject.com/en/2.2/ref/signals/#m2m-changed
3935
jobs = getattr(instance, reverse_lookup).all()
4036
else:
4137
jobs = model.objects.filter(pk__in=pk_set)
38+
39+
jobs = jobs.prefetch_related("viewer_groups").only("viewer_groups")
4240
else:
4341
jobs = [instance]
42+
4443
if pk_set is None:
4544
# When using a _clear action, pk_set is None
4645
# https://docs.djangoproject.com/en/2.2/ref/signals/#m2m-changed
47-
component_interface_values = getattr(
48-
instance, forward_lookup
49-
).filter(image__isnull=False)
46+
images = Image.objects.filter(
47+
**{f"componentinterfacevalue__{reverse_lookup}__in": jobs}
48+
)
5049
else:
51-
component_interface_values = model.objects.filter(
52-
pk__in=pk_set, image__isnull=False
50+
images = Image.objects.filter(
51+
componentinterfacevalue__pk__in=pk_set
5352
)
5453

55-
_update_image_permissions(
56-
jobs=jobs,
57-
component_interface_values=component_interface_values,
58-
exclude_jobs=action == "pre_clear",
59-
)
54+
images = images.distinct()
6055

56+
if action == "post_add":
57+
for job in jobs:
58+
for group in job.viewer_groups.all():
59+
assign_perm("view_image", group, images)
60+
61+
elif action in {"post_remove", "pre_clear"}:
62+
exclude_jobs = jobs if action == "pre_clear" else None
63+
64+
for image in images:
65+
# We cannot remove image permissions directly as the groups
66+
# may have permissions through another object
67+
image.update_viewer_groups_permissions(exclude_jobs=exclude_jobs)
68+
69+
else:
70+
raise NotImplementedError
6171

62-
def _update_image_permissions(
63-
*, jobs, component_interface_values, exclude_jobs: bool
64-
):
65-
for civ in component_interface_values:
66-
# image__isnull=False is used above so we know that civ.image exists
67-
civ.image.update_viewer_groups_permissions(
68-
exclude_jobs=jobs if exclude_jobs else None
69-
)
72+
73+
def _get_images_for_jobs(*, jobs):
74+
input_images = Image.objects.filter(
75+
componentinterfacevalue__algorithms_jobs_as_input__in=jobs
76+
)
77+
output_images = Image.objects.filter(
78+
componentinterfacevalue__algorithms_jobs_as_output__in=jobs
79+
)
80+
return input_images.union(output_images)
7081

7182

7283
@receiver(m2m_changed, sender=Job.viewer_groups.through)
73-
def update_group_permissions(
84+
def update_permissions_on_viewer_groups_change( # noqa:C901
7485
*_, instance, action, reverse, model, pk_set, **__
7586
):
7687
if action not in ["post_add", "post_remove", "pre_clear"]:
@@ -90,23 +101,34 @@ def update_group_permissions(
90101
else:
91102
groups = model.objects.filter(pk__in=pk_set)
92103

93-
operation = assign_perm if "add" in action else remove_perm
104+
images = _get_images_for_jobs(jobs=jobs)
94105

95-
for job in jobs:
106+
if action == "post_add":
96107
for group in groups:
97-
operation("view_job", group, job)
108+
assign_perm("view_job", group, jobs)
109+
assign_perm("view_image", group, images)
98110

99-
queryset = ComponentInterfaceValue.objects.filter(
100-
image__isnull=False
101-
).select_related("image")
111+
elif action in {"post_remove", "pre_clear"}:
112+
for group in groups:
113+
for job in jobs:
114+
remove_perm("view_job", group, job)
102115

103-
input_civs = queryset.filter(algorithms_jobs_as_input__in=jobs)
104-
output_civs = queryset.filter(algorithms_jobs_as_output__in=jobs)
116+
exclude_jobs = jobs if action == "pre_clear" else None
105117

106-
component_interface_values = {*input_civs, *output_civs}
118+
for image in images:
119+
# We cannot remove image permissions directly as the groups
120+
# may have permissions through another object
121+
image.update_viewer_groups_permissions(exclude_jobs=exclude_jobs)
107122

108-
_update_image_permissions(
109-
jobs=jobs,
110-
component_interface_values=component_interface_values,
111-
exclude_jobs=action == "pre_clear",
112-
)
123+
else:
124+
raise NotImplementedError
125+
126+
127+
@receiver(pre_delete, sender=Job)
128+
def update_view_image_permissions_on_job_deletion(*_, instance: Job, **__):
129+
jobs = [instance]
130+
131+
for image in _get_images_for_jobs(jobs=jobs):
132+
# We cannot remove image permissions directly as the groups
133+
# may have permissions through another object
134+
image.update_viewer_groups_permissions(exclude_jobs=jobs)

app/grandchallenge/core/admin.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from django.contrib.flatpages.admin import FlatPageAdmin
33
from django.contrib.flatpages.forms import FlatpageForm
44
from django.contrib.flatpages.models import FlatPage
5+
from django_celery_results.admin import TaskResultAdmin
6+
from django_celery_results.models import TaskResult
57

68
from grandchallenge.core.widgets import MarkdownEditorAdminWidget
79

@@ -29,3 +31,23 @@ class GroupObjectPermissionAdmin(admin.ModelAdmin):
2931

3032
admin.site.unregister(FlatPage)
3133
admin.site.register(FlatPage, MarkdownFlatPageAdmin)
34+
35+
36+
class TaskResultAdminWithDuration(TaskResultAdmin):
37+
list_display = (
38+
"task_id",
39+
"periodic_task_name",
40+
"task_name",
41+
"date_done",
42+
"get_duration",
43+
"status",
44+
"worker",
45+
)
46+
47+
@admin.display(description="Duration")
48+
def get_duration(self, obj):
49+
return obj.date_done - obj.date_started
50+
51+
52+
admin.site.unregister(TaskResult)
53+
admin.site.register(TaskResult, TaskResultAdminWithDuration)

app/grandchallenge/evaluation/tasks.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import uuid
22
from datetime import timedelta
33

4-
from billiard.exceptions import SoftTimeLimitExceeded
54
from celery.utils.log import get_task_logger
65
from django.conf import settings
76
from django.core.exceptions import ValidationError
@@ -202,12 +201,7 @@ def prepare_and_execute_evaluation(*, evaluation_pk):
202201

203202

204203
@acks_late_micro_short_task(
205-
retry_on=(
206-
TooManyJobsScheduled,
207-
LockNotAcquiredException,
208-
# TODO remove SoftTimeLimitExceeded, temporary workaround for https://github.com/DIAGNijmegen/rse-grand-challenge-admin/issues/663
209-
SoftTimeLimitExceeded,
210-
)
204+
retry_on=(TooManyJobsScheduled, LockNotAcquiredException)
211205
)
212206
@transaction.atomic
213207
def create_algorithm_jobs_for_evaluation(*, evaluation_pk, first_run):

app/tests/algorithms_tests/test_signals.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pytest
2+
from django.contrib.auth.models import Group
23
from guardian.shortcuts import get_perms
34

45
from tests.algorithms_tests.factories import AlgorithmJobFactory
56
from tests.algorithms_tests.utils import TwoAlgorithms
67
from tests.components_tests.factories import ComponentInterfaceValueFactory
8+
from tests.evaluation_tests.test_permissions import get_groups_with_set_perms
79
from tests.factories import GroupFactory, ImageFactory, UserFactory
810
from tests.utils import get_view_for_user
911

@@ -287,3 +289,24 @@ def test_group_clearing(self, reverse):
287289
assert "view_job" not in get_perms(group, job)
288290
assert "view_image" not in get_perms(group, civ_in.image)
289291
assert "view_image" not in get_perms(group, civ_out.image)
292+
293+
294+
@pytest.mark.django_db
295+
def test_permissions_removed_on_job_deletion(settings):
296+
job = AlgorithmJobFactory(time_limit=60, public=True)
297+
image = ImageFactory()
298+
299+
job.inputs.add(ComponentInterfaceValueFactory(image=image))
300+
301+
reg_and_anon = Group.objects.get(
302+
name=settings.REGISTERED_AND_ANON_USERS_GROUP_NAME
303+
)
304+
305+
assert get_groups_with_set_perms(image) == {
306+
reg_and_anon: {"view_image"},
307+
job.viewers: {"view_image"},
308+
}
309+
310+
job.delete()
311+
312+
assert get_groups_with_set_perms(image) == {}

0 commit comments

Comments
 (0)