Skip to content

Commit ea9a45c

Browse files
authored
Block changing interfaces for interactive algorithms (#4614)
Related to DIAGNijmegen/rse-roadmap#400
1 parent b344eff commit ea9a45c

8 files changed

Lines changed: 190 additions & 6 deletions

File tree

app/grandchallenge/algorithms/models.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,18 @@ def api_url(self) -> str:
391391

392392
@property
393393
def algorithm_interfaces_locked(self):
394-
return False
394+
if self.interactive_algorithms.exists():
395+
return True
396+
else:
397+
return False
398+
399+
@property
400+
def algorithm_interfaces_locked_message(self):
401+
return (
402+
"Interfaces cannot be changed because this is an interactive algorithm. "
403+
"Please contact support if this algorithm requires changes to its "
404+
"interfaces."
405+
)
395406

396407
def save(self, *args, **kwargs):
397408
adding = self._state.adding
@@ -667,6 +678,21 @@ class Meta:
667678
),
668679
]
669680

681+
def clean(self):
682+
super().clean()
683+
if self.algorithm.algorithm_interfaces_locked:
684+
raise ValidationError(
685+
self.algorithm.algorithm_interfaces_locked_message
686+
)
687+
688+
def save(self, *args, **kwargs):
689+
self.clean()
690+
return super().save(*args, **kwargs)
691+
692+
def delete(self, *args, **kwargs):
693+
self.clean()
694+
return super().delete(*args, **kwargs)
695+
670696
def __str__(self):
671697
return str(self.interface)
672698

app/grandchallenge/algorithms/signals.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from django.core.exceptions import ValidationError
12
from django.db.models.signals import m2m_changed, pre_delete
23
from django.dispatch import receiver
34
from guardian.shortcuts import assign_perm, remove_perm
45

5-
from grandchallenge.algorithms.models import Job
6+
from grandchallenge.algorithms.models import Algorithm, Job
67
from grandchallenge.cases.models import Image
78

89

@@ -132,3 +133,14 @@ def update_view_image_permissions_on_job_deletion(*_, instance: Job, **__):
132133
# We cannot remove image permissions directly as the groups
133134
# may have permissions through another object
134135
image.update_viewer_groups_permissions(exclude_jobs=jobs)
136+
137+
138+
@receiver(m2m_changed, sender=Algorithm.interfaces.through)
139+
def prevent_interfaces_update_for_interactive_algorithms(
140+
*_, instance, action, **__
141+
):
142+
if (
143+
action in {"pre_add", "pre_remove", "pre_clear"}
144+
and instance.algorithm_interfaces_locked
145+
):
146+
raise ValidationError(instance.algorithm_interfaces_locked_message)

app/grandchallenge/algorithms/templates/algorithms/partials/algorithminterface_list.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55

66
<h2>Algorithm Interfaces for {{ base_obj }}</h2>
77

8+
{% if base_obj.algorithm_interfaces_locked %}
9+
<div class="alert alert-info">
10+
{{ base_obj.algorithm_interfaces_locked_message }}
11+
</div>
12+
{% endif %}
813
<p>The following interfaces (i.e. input-output combinations) are configured for your {{ base_obj|verbose_name }}:</p>
914
<p>
10-
<span class="d-inline-block" tabindex="0" data-toggle="tooltip" title="{% if base_obj.algorithm_interfaces_locked %}Disabled because this phase is a parent or has a parent phase.{% endif %}">
15+
<span class="d-inline-block" tabindex="0" data-toggle="tooltip"
16+
title="{% if base_obj.algorithm_interfaces_locked %}{{ base_obj.algorithm_interfaces_locked_message }}{% endif %}">
1117
<a class="btn btn-primary {% if base_obj.algorithm_interfaces_locked %} disabled {% endif %}"
1218
href="{{ base_obj.algorithm_interface_create_url }}"
1319
>

app/grandchallenge/algorithms/templates/algorithms/partials/algorithminterface_table.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
{% if delete_option %}
3434
<td class="align-middle">
3535
<span class="d-inline-block" tabindex="0" data-toggle="tooltip"
36-
title="{% if base_obj.algorithm_interfaces_locked %}Disabled because this phase is a parent or has a parent phase.{% endif %}">
36+
title="{% if base_obj.algorithm_interfaces_locked %}{{ base_obj.algorithm_interfaces_locked_message }}{% endif %}">
3737
<a class="btn btn-sm btn-danger m-0 {% if base_obj.algorithm_interfaces_locked %} disabled {% endif %}"
3838
href="{% url base_obj.algorithm_interface_delete_viewname slug=base_obj.slug interface_pk=interface.pk %}"
3939
>

app/grandchallenge/algorithms/views.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,8 +1246,21 @@ def get_form_kwargs(self):
12461246
return kwargs
12471247

12481248

1249+
class AlgorithmInterfacesLockedMixin(AccessMixin):
1250+
def dispatch(self, request, *args, **kwargs):
1251+
if self.algorithm.algorithm_interfaces_locked:
1252+
messages.error(
1253+
request,
1254+
self.algorithm.algorithm_interfaces_locked_message,
1255+
)
1256+
return self.handle_no_permission()
1257+
return super().dispatch(request, *args, **kwargs)
1258+
1259+
12491260
class AlgorithmInterfaceForAlgorithmCreate(
1250-
AlgorithmInterfacePermissionMixin, AlgorithmInterfaceCreateBase
1261+
AlgorithmInterfacePermissionMixin,
1262+
AlgorithmInterfacesLockedMixin,
1263+
AlgorithmInterfaceCreateBase,
12511264
):
12521265
@property
12531266
def base_obj(self):
@@ -1290,7 +1303,9 @@ def get_context_data(self, *args, **kwargs):
12901303

12911304

12921305
class AlgorithmInterfaceForAlgorithmDelete(
1293-
AlgorithmInterfacePermissionMixin, DeleteView
1306+
AlgorithmInterfacePermissionMixin,
1307+
AlgorithmInterfacesLockedMixin,
1308+
DeleteView,
12941309
):
12951310
model = AlgorithmAlgorithmInterface
12961311
form_class = AlgorithmAlgorithmInterfaceDeleteForm

app/grandchallenge/evaluation/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,10 @@ def algorithm_interfaces_locked(self):
914914
else:
915915
return False
916916

917+
@property
918+
def algorithm_interfaces_locked_message(self):
919+
return "Disabled because this phase is a parent or has a parent phase."
920+
917921
@cached_property
918922
def valid_metrics(self):
919923
return (

app/tests/algorithms_tests/test_models.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,3 +1599,65 @@ def test_interactive_algorithm_mark_non_invoke_image_raises_validation_error():
15991599
"Only algorithm images that implement the invoke API can be activated because this is an interactive algorithm"
16001600
in str(error)
16011601
)
1602+
1603+
1604+
@pytest.mark.django_db
1605+
def test_cannot_set_interface_for_interactive_algorithm():
1606+
algorithm = AlgorithmFactory()
1607+
new_interface = AlgorithmInterfaceFactory()
1608+
InteractiveAlgorithmFactory(algorithm=algorithm)
1609+
1610+
with pytest.raises(ValidationError) as error:
1611+
algorithm.interfaces.set([new_interface])
1612+
1613+
assert (
1614+
"Interfaces cannot be changed because this is an interactive algorithm"
1615+
in str(error)
1616+
)
1617+
1618+
1619+
@pytest.mark.django_db
1620+
def test_cannot_add_interface_for_interactive_algorithm():
1621+
algorithm = AlgorithmFactory()
1622+
new_interface = AlgorithmInterfaceFactory()
1623+
InteractiveAlgorithmFactory(algorithm=algorithm)
1624+
1625+
with pytest.raises(ValidationError) as error:
1626+
algorithm.interfaces.add(new_interface)
1627+
1628+
assert (
1629+
"Interfaces cannot be changed because this is an interactive algorithm"
1630+
in str(error.value)
1631+
)
1632+
1633+
1634+
@pytest.mark.django_db
1635+
def test_cannot_remove_interface_for_interactive_algorithm():
1636+
algorithm = AlgorithmFactory()
1637+
interface = AlgorithmInterfaceFactory()
1638+
algorithm.interfaces.add(interface)
1639+
InteractiveAlgorithmFactory(algorithm=algorithm)
1640+
1641+
with pytest.raises(ValidationError) as error:
1642+
algorithm.interfaces.remove(interface)
1643+
1644+
assert (
1645+
"Interfaces cannot be changed because this is an interactive algorithm"
1646+
in str(error.value)
1647+
)
1648+
1649+
1650+
@pytest.mark.django_db
1651+
def test_cannot_clear_interface_for_interactive_algorithm():
1652+
algorithm = AlgorithmFactory()
1653+
interface = AlgorithmInterfaceFactory()
1654+
algorithm.interfaces.add(interface)
1655+
InteractiveAlgorithmFactory(algorithm=algorithm)
1656+
1657+
with pytest.raises(ValidationError) as error:
1658+
algorithm.interfaces.clear()
1659+
1660+
assert (
1661+
"Interfaces cannot be changed because this is an interactive algorithm"
1662+
in str(error.value)
1663+
)

app/tests/algorithms_tests/test_views.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
AlgorithmJobFactory,
4141
AlgorithmModelFactory,
4242
AlgorithmPermissionRequestFactory,
43+
InteractiveAlgorithmFactory,
4344
)
4445
from tests.cases_tests import RESOURCE_PATH
4546
from tests.components_tests.factories import (
@@ -2518,3 +2519,61 @@ def test_algorithm_users_list_is_filtered(client):
25182519
url=reverse("algorithms:users-list"), client=client, user=user
25192520
)
25202521
assert expected_algorithms == {*response.context[-1]["object_list"]}
2522+
2523+
2524+
@pytest.mark.django_db
2525+
def test_interactive_algorithm_interfaces_locked(client):
2526+
algorithm = AlgorithmFactory()
2527+
editor = UserFactory()
2528+
algorithm.add_editor(editor)
2529+
assign_perm("algorithms.add_algorithm", editor)
2530+
interface = AlgorithmInterfaceFactory()
2531+
algorithm.interfaces.add(interface)
2532+
2533+
response = get_view_for_user(
2534+
viewname="algorithms:interface-create",
2535+
client=client,
2536+
user=editor,
2537+
reverse_kwargs={
2538+
"slug": algorithm.slug,
2539+
},
2540+
)
2541+
2542+
assert response.status_code == 200
2543+
2544+
response = get_view_for_user(
2545+
viewname="algorithms:interface-delete",
2546+
client=client,
2547+
user=editor,
2548+
reverse_kwargs={
2549+
"slug": algorithm.slug,
2550+
"interface_pk": interface.pk,
2551+
},
2552+
)
2553+
2554+
assert response.status_code == 200
2555+
2556+
InteractiveAlgorithmFactory(algorithm=algorithm)
2557+
2558+
response = get_view_for_user(
2559+
viewname="algorithms:interface-create",
2560+
client=client,
2561+
user=editor,
2562+
reverse_kwargs={
2563+
"slug": algorithm.slug,
2564+
},
2565+
)
2566+
2567+
assert response.status_code == 403
2568+
2569+
response = get_view_for_user(
2570+
viewname="algorithms:interface-delete",
2571+
client=client,
2572+
user=editor,
2573+
reverse_kwargs={
2574+
"slug": algorithm.slug,
2575+
"interface_pk": interface.pk,
2576+
},
2577+
)
2578+
2579+
assert response.status_code == 403

0 commit comments

Comments
 (0)