Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable interface selection for algorithm job #3753

Merged
merged 17 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/grandchallenge/algorithms/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ class JobAdmin(GuardedModelAdmin):
"task_on_success",
"task_on_failure",
"runtime_metrics",
"algorithm_interface",
)
search_fields = (
"creator__username",
Expand Down
57 changes: 53 additions & 4 deletions app/grandchallenge/algorithms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
TextInput,
URLField,
)
from django.forms.widgets import MultipleHiddenInput, PasswordInput
from django.forms.widgets import (
MultipleHiddenInput,
PasswordInput,
RadioSelect,
)
from django.urls import Resolver404, resolve
from django.utils.functional import cached_property
from django.utils.html import format_html
Expand Down Expand Up @@ -116,10 +120,18 @@ class JobCreateForm(SaveFormInitMixin, Form):
creator = ModelChoiceField(
queryset=None, disabled=True, required=False, widget=HiddenInput
)
algorithm_interface = ModelChoiceField(
queryset=None,
disabled=True,
required=True,
widget=HiddenInput,
)

def __init__(self, *args, algorithm, user, **kwargs):
def __init__(self, *args, algorithm, user, interface, **kwargs):
super().__init__(*args, **kwargs)

self._algorithm = algorithm

self.helper = FormHelper()

self._user = user
Expand All @@ -128,7 +140,11 @@ def __init__(self, *args, algorithm, user, **kwargs):
)
self.fields["creator"].initial = self._user

self._algorithm = algorithm
self.fields["algorithm_interface"].queryset = (
self._algorithm.interfaces.all()
)
self.fields["algorithm_interface"].initial = interface

self._algorithm_image = self._algorithm.active_image

active_model = self._algorithm.active_model
Expand All @@ -145,7 +161,7 @@ def __init__(self, *args, algorithm, user, **kwargs):
)
self.fields["algorithm_model"].initial = active_model

for inp in self._algorithm.inputs.all():
for inp in interface.inputs.all():
prefixed_interface_slug = (
f"{INTERFACE_FORM_FIELD_PREFIX}{inp.slug}"
)
Expand Down Expand Up @@ -1427,3 +1443,36 @@ def save(self):
)

return interface


class JobInterfaceSelectForm(SaveFormInitMixin, Form):
algorithm_interface = ModelChoiceField(
queryset=None,
required=True,
help_text="Select an input-output combination to use for this job.",
widget=RadioSelect,
)

def __init__(self, *args, algorithm, **kwargs):
super().__init__(*args, **kwargs)

self._algorithm = algorithm

self.fields["algorithm_interface"].queryset = (
self._algorithm.interfaces.all()
)
self.fields["algorithm_interface"].initial = (
self._algorithm.default_interface_for_job
)
self.fields["algorithm_interface"].widget.choices = {
(
interface.pk,
format_html(
"<div>Inputs: {inputs}</div>"
"<div class='mb-3'>Outputs: {outputs}</div>",
inputs=oxford_comma(interface.inputs.all()),
outputs=oxford_comma(interface.outputs.all()),
),
)
for interface in self._algorithm.interfaces.all()
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,14 @@ class Migration(migrations.Migration):
name="unique_algorithm_interface_combination",
),
),
migrations.AddField(
model_name="job",
name="algorithm_interface",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="algorithms.algorithminterface",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import migrations


def add_algorithm_interfaces_to_jobs(apps, _schema_editor):
Algorithm = apps.get_model("algorithms", "Algorithm") # noqa: N806
Job = apps.get_model("algorithms", "Job") # noqa: N806

for algorithm in Algorithm.objects.prefetch_related("interfaces").all():
default_interface = algorithm.interfaces.get(
algorithmalgorithminterface__is_default=True
)
jobs = Job.objects.filter(algorithm_image__algorithm=algorithm)
amickan marked this conversation as resolved.
Show resolved Hide resolved
for job in jobs:
job.algorithm_interface = default_interface
jobs.bulk_update(jobs, ["algorithm_interface"])
amickan marked this conversation as resolved.
Show resolved Hide resolved


class Migration(migrations.Migration):
dependencies = [
(
"algorithms",
"0064_create_algorithm_interfaces",
),
]

operations = [
migrations.RunPython(add_algorithm_interfaces_to_jobs, elidable=True),
amickan marked this conversation as resolved.
Show resolved Hide resolved
]
20 changes: 19 additions & 1 deletion app/grandchallenge/algorithms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
public_s3_storage,
)
from grandchallenge.core.templatetags.bleach import md2html
from grandchallenge.core.templatetags.remove_whitespace import oxford_comma
from grandchallenge.core.utils.access_requests import (
AccessRequestHandlingOptions,
process_access_request,
Expand Down Expand Up @@ -106,6 +107,9 @@ class AlgorithmInterface(UUIDModel):

objects = AlgorithmInterfaceManager()

def __str__(self):
return f"Inputs: {oxford_comma(self.inputs.all())} \n Outputs: {oxford_comma(self.outputs.all())}"

def delete(self, *args, **kwargs):
raise ValidationError("AlgorithmInterfaces cannot be deleted.")

Expand Down Expand Up @@ -513,6 +517,17 @@ def default_interface(self):
except ObjectDoesNotExist:
return None

@cached_property
def requires_interface_selection_for_job(self):
return self.interfaces.count() != 1

@cached_property
def default_interface_for_job(self):
if self.requires_interface_selection_for_job:
return self.default_interface
else:
return self.interfaces.first()

def is_editor(self, user):
return user.groups.filter(pk=self.editors_group.pk).exists()

Expand Down Expand Up @@ -1072,6 +1087,9 @@ class Job(CIVForObjectMixin, ComponentJob):
algorithm_model = models.ForeignKey(
AlgorithmModel, on_delete=models.PROTECT, null=True, blank=True
)
algorithm_interface = models.ForeignKey(
AlgorithmInterface, on_delete=models.PROTECT, null=True, blank=True
)
amickan marked this conversation as resolved.
Show resolved Hide resolved
creator = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
)
Expand Down Expand Up @@ -1125,7 +1143,7 @@ def inputs_complete(self):
# check if all inputs are present and if they all have a value
return {
civ.interface for civ in self.inputs.all() if civ.has_value
} == {*self.algorithm_image.algorithm.inputs.all()}
} == {*self.algorithm_interface.inputs.all()}

@cached_property
def rendered_result_text(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@

{% if "execute_algorithm" in algorithm_perms and object.active_image %}
<a class="nav-link"
href="{% url 'algorithms:job-create' slug=object.slug %}">
href="{% url 'algorithms:job-interface-select' slug=object.slug %}">
<i class="fas fa-file-import fa-fw"></i>&nbsp;Try-out Algorithm
</a>
{% endif %}
Expand Down Expand Up @@ -118,7 +118,7 @@

{% if object.public and not object.public_test_case %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle mr-1"></i> Please upload a public test case for your latest algorithm image <a href="{% url 'algorithms:job-create' slug=object.slug %}">here</a>.
<i class="fas fa-exclamation-triangle mr-1"></i> Please upload a public test case for your latest algorithm image <a href="{% url 'algorithms:job-interface-select' slug=object.slug %}">here</a>.
</div>
{% endif %}
{% endif %}
Expand Down Expand Up @@ -618,7 +618,7 @@ <h5 class="modal-title">Make this algorithm public</h5>
<div class="col-12 d-flex justify-content-center">
<a type="button" class="btn btn-sm btn-secondary mr-1" href="{% url 'algorithms:update' slug=object.slug %}">Update Settings</a>
<a type="button" class="btn btn-sm btn-secondary mr-1" href="{% url 'algorithms:description-update' slug=object.slug %}">Update Description</a>
<a type="button" class="btn btn-sm btn-secondary" href="{% url 'algorithms:job-create' slug=object.slug %}">Add Test Case</a>
<a type="button" class="btn btn-sm btn-secondary" href="{% url 'algorithms:job-interface-select' slug=object.slug %}">Add Test Case</a>
</div>
<div class="col-12 d-flex justify-content-center">
<a type="button" class="btn btn-sm btn-primary {% if not object.mechanism or not object.summary or not object.public_test_case or not object.contact_email or not object.display_editors %}disabled{% endif %}" hx-post="{% url 'algorithms:publish' slug=object.slug %}" hx-vals='{"public": true}'>Publish algorithm</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h2>Results for {{ algorithm.title }}</h2>
{% if "execute_algorithm" in algorithm_perms and algorithm.active_image %}
<p>
<a class="btn btn-primary"
href="{% url 'algorithms:job-create' slug=algorithm.slug %}">
href="{% url 'algorithms:job-interface-select' slug=algorithm.slug %}">
<i class="fas fa-file-import fa-fw"></i>&nbsp;Try-out Algorithm
</a>
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ <h5 class="card-title">Creating inputs</h5>
<i class="text-success fa fa-check fa-2x"></i>
</div>
</div>
<a href="{% url 'algorithms:job-create' slug=object.algorithm_image.algorithm.slug %}"
<a href="{% url 'algorithms:job-interface-select' slug=object.algorithm_image.algorithm.slug %}"
class="stretched-link"></a>
</div>
</div>
Expand Down
12 changes: 11 additions & 1 deletion app/grandchallenge/algorithms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
EditorsUpdate,
JobCreate,
JobDetail,
JobInterfaceSelect,
JobProgressDetail,
JobsList,
JobStatusDetail,
Expand Down Expand Up @@ -133,7 +134,16 @@
name="model-update",
),
path("<slug>/jobs/", JobsList.as_view(), name="job-list"),
path("<slug>/jobs/create/", JobCreate.as_view(), name="job-create"),
path(
"<slug>/jobs/interface-select/",
JobInterfaceSelect.as_view(),
name="job-interface-select",
),
path(
"<slug>/<interface>/jobs/create/",
JobCreate.as_view(),
name="job-create",
),
amickan marked this conversation as resolved.
Show resolved Hide resolved
path("<slug>/jobs/<uuid:pk>/", JobDetail.as_view(), name="job-detail"),
path(
"<slug>/jobs/<uuid:pk>/status/",
Expand Down
68 changes: 63 additions & 5 deletions app/grandchallenge/algorithms/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
ImageActivateForm,
JobCreateForm,
JobForm,
JobInterfaceSelectForm,
UsersForm,
ViewersForm,
)
Expand Down Expand Up @@ -478,15 +479,11 @@ def get_success_url(self):
return self.algorithm.get_absolute_url()


class JobCreate(
class JobCreatePermissionMixin(
amickan marked this conversation as resolved.
Show resolved Hide resolved
LoginRequiredMixin,
ObjectPermissionRequiredMixin,
VerificationRequiredMixin,
UserFormKwargsMixin,
FormView,
):
form_class = JobCreateForm
template_name = "algorithms/job_form_create.html"
permission_required = "algorithms.execute_algorithm"
raise_exception = True

Expand All @@ -497,6 +494,22 @@ def algorithm(self) -> Algorithm:
def get_permission_object(self):
return self.algorithm


class JobInterfaceSelect(
JobCreatePermissionMixin,
FormView,
):
form_class = JobInterfaceSelectForm
template_name = "algorithms/job_form_create.html"
selected_interface = None

def get(self, request, *args, **kwargs):
if self.algorithm.requires_interface_selection_for_job:
return super().get(request, *args, **kwargs)
else:
self.selected_interface = self.algorithm.default_interface_for_job
return HttpResponseRedirect(self.get_success_url())

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"algorithm": self.algorithm})
Expand All @@ -512,6 +525,51 @@ def get_context_data(self, *args, **kwargs):
)
return context

def form_valid(self, form):
self.selected_interface = form.cleaned_data.pop("algorithm_interface")
amickan marked this conversation as resolved.
Show resolved Hide resolved
return super().form_valid(form)

def get_success_url(self):
return reverse(
"algorithms:job-create",
kwargs={
"slug": self.algorithm.slug,
"interface": self.selected_interface.pk,
},
)


class JobCreate(
JobCreatePermissionMixin,
UserFormKwargsMixin,
FormView,
):
form_class = JobCreateForm
template_name = "algorithms/job_form_create.html"

@cached_property
def interface(self):
return get_object_or_404(
AlgorithmInterface, pk=self.kwargs["interface"]
)

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(
{"algorithm": self.algorithm, "interface": self.interface}
)
return kwargs

def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context.update(
{
"algorithm": self.algorithm,
"editors_job_limit": settings.ALGORITHM_IMAGES_COMPLIMENTARY_EDITOR_JOBS,
}
)
return context

def form_valid(self, form):
inputs = form.cleaned_data.pop("inputs")

Expand Down
7 changes: 7 additions & 0 deletions app/tests/algorithms_tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ def test_permission_required_views(self, client):
ai.algorithm,
None,
),
(
"job-interface-select",
{"slug": ai.algorithm.slug},
"execute_algorithm",
ai.algorithm,
None,
),
(
"job-progress-detail",
{"slug": ai.algorithm.slug, "pk": j.pk},
Expand Down
Loading