Skip to content

Commit

Permalink
Add AlgorithmInterface model (#3720)
Browse files Browse the repository at this point in the history
First PR for DIAGNijmegen/rse-roadmap#153

This adds the `AlgorithmInterface` model, as well as the necessary
through table, and removes the inputs and outputs fields from the
algorithm form.

The following restrictions apply:
- AlgorithmInterfaces cannot be deleted (protection on model level and
admin form level)
- AlgorithmInterfaces cannot be updated (protection on admin form level)
- AlgorithmInterfaces are unique (i.e. input and output combinations are
unique, we check for that in the form)
- An algorithm can only have 1 default algorithm interface
- There can only be one entry per algorithm and interface in the through
table.

---------

Co-authored-by: James Meakin <12661555+jmsmkn@users.noreply.github.com>
  • Loading branch information
amickan and jmsmkn authored Dec 6, 2024
1 parent dba7cdb commit 40156db
Show file tree
Hide file tree
Showing 11 changed files with 569 additions and 134 deletions.
64 changes: 60 additions & 4 deletions app/grandchallenge/algorithms/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import Count, Sum
from django.forms import ModelForm
from django.utils.html import format_html
from guardian.admin import GuardedModelAdmin

from grandchallenge.algorithms.forms import AlgorithmIOValidationMixin
from grandchallenge.algorithms.forms import AlgorithmInterfaceBaseForm
from grandchallenge.algorithms.models import (
Algorithm,
AlgorithmAlgorithmInterface,
AlgorithmGroupObjectPermission,
AlgorithmImage,
AlgorithmImageGroupObjectPermission,
AlgorithmImageUserObjectPermission,
AlgorithmInterface,
AlgorithmModel,
AlgorithmModelGroupObjectPermission,
AlgorithmModelUserObjectPermission,
Expand All @@ -36,24 +38,26 @@
UserObjectPermissionAdmin,
)
from grandchallenge.core.templatetags.costs import millicents_to_euro
from grandchallenge.core.templatetags.remove_whitespace import oxford_comma
from grandchallenge.core.utils.grand_challenge_forge import (
get_forge_algorithm_template_context,
)


class AlgorithmAdminForm(AlgorithmIOValidationMixin, ModelForm):
class AlgorithmAdminForm(ModelForm):
class Meta:
model = Algorithm
fields = "__all__"


@admin.register(Algorithm)
class AlgorithmAdmin(GuardedModelAdmin):
readonly_fields = ("algorithm_forge_json",)
readonly_fields = ("algorithm_forge_json", "inputs", "outputs")
list_display = (
"title",
"created",
"public",
"default_io",
"time_limit",
"job_requires_gpu_type",
"job_requires_memory_gb",
Expand All @@ -75,6 +79,11 @@ def algorithm_forge_json(obj):
"<pre>{json_desc}</pre>", json_desc=json.dumps(json_desc, indent=2)
)

def default_io(self, obj):
return AlgorithmAlgorithmInterface.objects.get(
algorithm=obj, is_default=True
)

def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(
Expand Down Expand Up @@ -235,6 +244,53 @@ class AlgorithmModelAdmin(GuardedModelAdmin):
readonly_fields = ("creator", "algorithm", "sha256", "size_in_storage")


class AlgorithmInterfaceAdminForm(AlgorithmInterfaceBaseForm):
def clean(self):
cleaned_data = super().clean()
if cleaned_data["existing_io"]:
raise ValidationError(
"An AlgorithmIO with the same combination of inputs and outputs already exists."
)
return cleaned_data


@admin.register(AlgorithmInterface)
class AlgorithmInterfaceAdmin(GuardedModelAdmin):
list_display = (
"pk",
"algorithm_inputs",
"algorithm_outputs",
)
search_fields = (
"inputs__slug",
"outputs__slug",
)
form = AlgorithmInterfaceAdminForm

def algorithm_inputs(self, obj):
return oxford_comma(obj.inputs.all())

def algorithm_outputs(self, obj):
return oxford_comma(obj.outputs.all())

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


@admin.register(AlgorithmAlgorithmInterface)
class AlgorithmAlgorithmInterfaceAdmin(GuardedModelAdmin):
list_display = (
"pk",
"interface",
"is_default",
"algorithm",
)
list_filter = ("is_default", "algorithm")


admin.site.register(AlgorithmUserObjectPermission, UserObjectPermissionAdmin)
admin.site.register(AlgorithmGroupObjectPermission, GroupObjectPermissionAdmin)
admin.site.register(AlgorithmImage, ComponentImageAdmin)
Expand Down
98 changes: 37 additions & 61 deletions app/grandchallenge/algorithms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
HiddenInput,
ModelChoiceField,
ModelForm,
ModelMultipleChoiceField,
Select,
TextInput,
URLField,
Expand All @@ -45,9 +44,11 @@
from grandchallenge.algorithms.models import (
Algorithm,
AlgorithmImage,
AlgorithmInterface,
AlgorithmModel,
AlgorithmPermissionRequest,
Job,
get_existing_interface_for_inputs_and_outputs,
)
from grandchallenge.algorithms.serializers import (
AlgorithmImageSerializer,
Expand Down Expand Up @@ -220,60 +221,11 @@ def reformat_inputs(self, *, cleaned_data):
]


class AlgorithmIOValidationMixin:
def clean(self):
cleaned_data = super().clean()

duplicate_interfaces = {*cleaned_data.get("inputs", [])}.intersection(
{*cleaned_data.get("outputs", [])}
)

if duplicate_interfaces:
raise ValidationError(
f"The sets of Inputs and Outputs must be unique: "
f"{oxford_comma(duplicate_interfaces)} present in both"
)

return cleaned_data


class AlgorithmForm(
AlgorithmIOValidationMixin,
WorkstationUserFilterMixin,
SaveFormInitMixin,
ModelForm,
):
inputs = ModelMultipleChoiceField(
queryset=ComponentInterface.objects.exclude(
slug__in=[*NON_ALGORITHM_INTERFACES, "results-json-file"]
),
widget=Select2MultipleWidget,
help_text=format_lazy(
(
"The inputs to this algorithm. "
'See the <a href="{}">list of interfaces</a> for more '
"information about each interface. "
"Please contact support if your desired input is missing."
),
reverse_lazy("components:component-interface-list-algorithms"),
),
)
outputs = ModelMultipleChoiceField(
queryset=ComponentInterface.objects.exclude(
slug__in=NON_ALGORITHM_INTERFACES
),
widget=Select2MultipleWidget,
help_text=format_lazy(
(
"The outputs to this algorithm. "
'See the <a href="{}">list of interfaces</a> for more '
"information about each interface. "
"Please contact support if your desired output is missing."
),
reverse_lazy("components:component-interface-list-algorithms"),
),
)

class Meta:
model = Algorithm
fields = (
Expand All @@ -293,8 +245,6 @@ class Meta:
"hanging_protocol",
"optional_hanging_protocols",
"view_content",
"inputs",
"outputs",
"minimum_credits_per_job",
"job_requires_gpu_type",
"job_requires_memory_gb",
Expand Down Expand Up @@ -638,15 +588,6 @@ def __init__(self, *args, **kwargs):
)


class AlgorithmUpdateForm(AlgorithmForm):
def __init__(self, *args, interfaces_editable, **kwargs):
super().__init__(*args, **kwargs)

if not interfaces_editable:
for field_key in ("inputs", "outputs"):
self.fields.pop(field_key)


class AlgorithmImageForm(ContainerImageForm):
algorithm = ModelChoiceField(widget=HiddenInput(), queryset=None)

Expand Down Expand Up @@ -1289,6 +1230,7 @@ def __init__(
)

if hide_algorithm_model_input:

self.fields["algorithm_model"].widget = HiddenInput()
self.helper = FormHelper(self)
if activate:
Expand Down Expand Up @@ -1317,3 +1259,37 @@ def clean_algorithm_model(self):
raise ValidationError("Model updating already in progress.")

return algorithm_model


class AlgorithmInterfaceBaseForm(SaveFormInitMixin, ModelForm):
class Meta:
model = AlgorithmInterface
fields = ("inputs", "outputs")

def clean(self):
cleaned_data = super().clean()

inputs = cleaned_data.get("inputs", [])
outputs = cleaned_data.get("outputs", [])

if not inputs or not outputs:
raise ValidationError(
"You must provide at least 1 input and 1 output."
)

duplicate_interfaces = {*inputs}.intersection({*outputs})

if duplicate_interfaces:
raise ValidationError(
f"The sets of Inputs and Outputs must be unique: "
f"{oxford_comma(duplicate_interfaces)} present in both"
)

cleaned_data["existing_io"] = (
get_existing_interface_for_inputs_and_outputs(
inputs=inputs,
outputs=outputs,
)
)

return cleaned_data
Loading

0 comments on commit 40156db

Please sign in to comment.