Skip to content

Commit

Permalink
Form to turn phase into algorithm phase (#3265)
Browse files Browse the repository at this point in the history
This PR adds a new view to turn prediction phases into algorithm phases.
It adds a button to do so to the Challenge list view.
The form takes the phases and the algorithm inputs and outputs as
values:

![image](https://github.com/comic/grand-challenge.org/assets/30069334/24602b58-23eb-4a36-8bc4-3ce1925231be)

The link to this form is both on the challenge request detail page: 

![image](https://github.com/comic/grand-challenge.org/assets/30069334/eaee49e8-7ea6-444e-9207-965562df7760)


and in the challenge admin:

![image](https://github.com/comic/grand-challenge.org/assets/30069334/617a1227-59e1-4874-b3db-5b2f7398acd8)


Closes #2942
  • Loading branch information
amickan authored Mar 4, 2024
1 parent 1a72abf commit 97d47e0
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 4 deletions.
12 changes: 12 additions & 0 deletions app/grandchallenge/challenges/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from grandchallenge.core.utils.grand_challenge_forge import (
get_forge_json_description,
)
from grandchallenge.subdomains.utils import reverse


@admin.register(Challenge)
Expand All @@ -29,6 +30,7 @@ class ChallengeAdmin(ModelAdmin):
"short_name",
"creator",
"challenge_forge_json",
"algorithm_phase_configuration_link",
)
autocomplete_fields = ("publications",)
ordering = ("-created",)
Expand Down Expand Up @@ -57,6 +59,16 @@ def challenge_forge_json(obj):
"<pre>{json_desc}</pre>", json_desc=json.dumps(json_desc, indent=2)
)

@staticmethod
def algorithm_phase_configuration_link(obj):
return format_html(
'<a href="{link}">{link}</a>',
link=reverse(
"evaluation:configure-algorithm-phases",
kwargs={"challenge_short_name": obj.short_name},
),
)


@admin.register(ChallengeRequest)
class ChallengeRequestAdmin(ModelAdmin):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ <h3>[{{ object.short_name }}] {{ object.title }}</h3>
{% if not object.structured_challenge_submission_form and not object.structured_challenge_submission_doi %}
<i class="fa fa-paperclip text-primary pr-3"></i> No challenge submission form available
{% endif %}
{% if object.status == object.ChallengeRequestStatusChoices.ACCEPTED and perms.evaluation.configure_algorithm_phase %}
<a class="btn btn-primary btn-sm mt-3"
href="{% url 'evaluation:configure-algorithm-phases' challenge_short_name=object.short_name %}"
title="Configure algorithm phases">
<i class="fas fa-edit mr-1"></i> Configure algorithm phases
</a>
{% endif %}
</div>
</div>
<div class="table-responsive">
Expand Down
58 changes: 56 additions & 2 deletions app/grandchallenge/evaluation/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.forms import CheckboxInput, HiddenInput, ModelChoiceField
from django.forms import (
CheckboxInput,
CheckboxSelectMultiple,
Form,
HiddenInput,
IntegerField,
ModelChoiceField,
ModelMultipleChoiceField,
)
from django.utils.html import format_html
from django.utils.text import format_lazy
from django_select2.forms import Select2MultipleWidget
from django_summernote.widgets import SummernoteInplaceWidget

from grandchallenge.algorithms.forms import UserAlgorithmsForPhaseMixin
from grandchallenge.challenges.models import Challenge
from grandchallenge.challenges.models import Challenge, ChallengeRequest
from grandchallenge.components.forms import ContainerImageForm
from grandchallenge.components.models import ComponentInterface
from grandchallenge.core.forms import (
SaveFormInitMixin,
WorkstationUserFilterMixin,
Expand Down Expand Up @@ -539,3 +549,47 @@ def clean(self):
raise ValidationError("This challenge has exceeded its budget")

return cleaned_data


class ConfigureAlgorithmPhasesForm(SaveFormInitMixin, Form):
phases = ModelMultipleChoiceField(
queryset=None,
widget=CheckboxSelectMultiple,
)
algorithm_inputs = ModelMultipleChoiceField(
queryset=ComponentInterface.objects.all(),
widget=Select2MultipleWidget,
)
algorithm_outputs = ModelMultipleChoiceField(
queryset=ComponentInterface.objects.all(),
widget=Select2MultipleWidget,
)
algorithm_time_limit = IntegerField(
widget=forms.HiddenInput(),
disabled=True,
)

def __init__(self, *args, challenge, **kwargs):
super().__init__(*args, **kwargs)
self.fields["phases"].queryset = (
Phase.objects.select_related("challenge")
.filter(
challenge=challenge,
submission_kind=SubmissionKindChoices.CSV,
submission__isnull=True,
method__isnull=True,
)
.all()
)

try:
challenge_request = ChallengeRequest.objects.get(
short_name=challenge.short_name
)
self.fields["algorithm_time_limit"].initial = (
challenge_request.inference_time_limit_in_minutes * 60
)
except ObjectDoesNotExist:
self.fields["algorithm_time_limit"].initial = (
Phase._meta.get_field("algorithm_time_limit").get_default()
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-02-28 12:35

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("evaluation", "0049_alter_phase_options"),
]

operations = [
migrations.AlterModelOptions(
name="phase",
options={
"ordering": ("challenge", "submissions_open_at", "created"),
"permissions": (
("create_phase_submission", "Create Phase Submission"),
("configure_algorithm_phase", "Configure Algorithm Phase"),
),
},
),
]
5 changes: 4 additions & 1 deletion app/grandchallenge/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,10 @@ class Phase(FieldChangeMixin, HangingProtocolMixin, UUIDModel):
class Meta:
unique_together = (("challenge", "title"), ("challenge", "slug"))
ordering = ("challenge", "submissions_open_at", "created")
permissions = (("create_phase_submission", "Create Phase Submission"),)
permissions = (
("create_phase_submission", "Create Phase Submission"),
("configure_algorithm_phase", "Configure Algorithm Phase"),
)

def __str__(self):
return f"{self.title} Evaluation for {self.challenge.short_name}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load url %}

{% block breadcrumbs %}
<ol class="breadcrumb">
<li class="breadcrumb-item"><a
href="{% url 'challenges:list' %}">Challenges</a>
</li>
<li class="breadcrumb-item"><a
href="{{ challenge.get_absolute_url }}">{% firstof challenge.title challenge.short_name %}</a></li>
<li class="breadcrumb-item active"
aria-current="page">Configure algorithm phases</li>
</ol>
{% endblock %}

{% block content %}
<h3>Configure algorithm submission phases</h3>
{% crispy form %}
{% endblock %}

{% block script %}
{{ block.super }}
{{ form.media }}
{% endblock %}
6 changes: 6 additions & 0 deletions app/grandchallenge/evaluation/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CombinedLeaderboardDelete,
CombinedLeaderboardDetail,
CombinedLeaderboardUpdate,
ConfigureAlgorithmPhasesView,
EvaluationAdminList,
EvaluationCreate,
EvaluationDetail,
Expand All @@ -31,6 +32,11 @@
# UUID should be matched before slugs
path("<uuid:pk>/update/", EvaluationUpdate.as_view(), name="update"),
path("phase/create/", PhaseCreate.as_view(), name="phase-create"),
path(
"configure-algorithm-phases/",
ConfigureAlgorithmPhasesView.as_view(),
name="configure-algorithm-phases",
),
path("submissions/", SubmissionList.as_view(), name="submission-list"),
path(
"combined-leaderboards/create/",
Expand Down
66 changes: 66 additions & 0 deletions app/grandchallenge/evaluation/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.timezone import now
from django.views.generic import (
CreateView,
Expand All @@ -24,7 +26,9 @@

from grandchallenge.algorithms.forms import AlgorithmForPhaseForm
from grandchallenge.algorithms.models import Algorithm, Job
from grandchallenge.archives.models import Archive
from grandchallenge.components.models import InterfaceKind
from grandchallenge.core.fixtures import create_uploaded_image
from grandchallenge.core.forms import UserFormKwargsMixin
from grandchallenge.core.guardian import (
ObjectPermissionRequiredMixin,
Expand All @@ -35,6 +39,7 @@
from grandchallenge.direct_messages.forms import ConversationForm
from grandchallenge.evaluation.forms import (
CombinedLeaderboardForm,
ConfigureAlgorithmPhasesForm,
EvaluationForm,
MethodForm,
MethodUpdateForm,
Expand All @@ -54,6 +59,7 @@
from grandchallenge.subdomains.utils import reverse, reverse_lazy
from grandchallenge.teams.models import Team
from grandchallenge.verifications.views import VerificationRequiredMixin
from grandchallenge.workstations.models import Workstation


class CachedPhaseMixin:
Expand Down Expand Up @@ -980,3 +986,63 @@ def get_success_url(self):

def get_permission_object(self):
return self.get_object().challenge


class ConfigureAlgorithmPhasesView(PermissionRequiredMixin, FormView):
form_class = ConfigureAlgorithmPhasesForm
permission_required = "evaluation.configure_algorithm_phase"
template_name = "evaluation/configure_algorithm_phases_form.html"
raise_exception = True

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"challenge": self.request.challenge})
return kwargs

def form_valid(self, form):
for phase in form.cleaned_data["phases"]:
self.turn_phase_into_algorithm_phase(
phase=phase,
inputs=form.cleaned_data["algorithm_inputs"],
outputs=form.cleaned_data["algorithm_outputs"],
algorithm_time_limit=form.cleaned_data["algorithm_time_limit"],
)
messages.success(self.request, "Phases were successfully updated")
return super().form_valid(form)

def get_success_url(self):
return reverse(
"challenges:requests-list",
)

def turn_phase_into_algorithm_phase(
self, *, phase, inputs, outputs, algorithm_time_limit
):
archive = Archive.objects.create(
title=format_html(
"{challenge_name} {phase_title} dataset",
challenge_name=phase.challenge.short_name,
phase_title=phase.title,
),
workstation=Workstation.objects.get(
slug=settings.DEFAULT_WORKSTATION_SLUG
),
logo=(
phase.challenge.logo
if phase.challenge.logo
else create_uploaded_image()
),
)
archive.full_clean()
archive.save()

for user in phase.challenge.admins_group.user_set.all():
archive.add_editor(user)

phase.algorithm_time_limit = algorithm_time_limit
phase.archive = archive
phase.submission_kind = phase.SubmissionKindChoices.ALGORITHM
phase.creator_must_be_verified = True
phase.save()
phase.algorithm_outputs.add(*outputs)
phase.algorithm_inputs.add(*inputs)
33 changes: 32 additions & 1 deletion app/tests/evaluation_tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

from grandchallenge.algorithms.forms import AlgorithmForPhaseForm
from grandchallenge.algorithms.models import AlgorithmImage
from grandchallenge.evaluation.forms import SubmissionForm
from grandchallenge.evaluation.forms import (
ConfigureAlgorithmPhasesForm,
SubmissionForm,
)
from grandchallenge.evaluation.models import Phase, Submission
from grandchallenge.evaluation.utils import SubmissionKindChoices
from grandchallenge.invoices.models import PaymentStatusChoices
Expand All @@ -20,10 +23,12 @@
)
from tests.evaluation_tests.factories import (
EvaluationFactory,
MethodFactory,
PhaseFactory,
SubmissionFactory,
)
from tests.factories import (
ChallengeFactory,
UserFactory,
WorkstationConfigFactory,
WorkstationFactory,
Expand Down Expand Up @@ -529,3 +534,29 @@ def test_algorithm_for_phase_form_validation():
"You have already created the maximum number of algorithms for this phase."
in str(form.errors)
)


@pytest.mark.django_db
def test_configure_algorithm_phases_form():
ch = ChallengeFactory()
p1, p2, p3 = PhaseFactory.create_batch(
3, challenge=ch, submission_kind=SubmissionKindChoices.CSV
)
PhaseFactory(submission_kind=SubmissionKindChoices.CSV)
SubmissionFactory(phase=p1)
MethodFactory(phase=p2)
PhaseFactory(submission_kind=SubmissionKindChoices.ALGORITHM)
ci1, ci2 = ComponentInterfaceFactory.create_batch(2)

form = ConfigureAlgorithmPhasesForm(challenge=ch)
assert list(form.fields["phases"].queryset) == [p3]

form3 = ConfigureAlgorithmPhasesForm(
challenge=ch,
data={
"phases": [p3],
"algorithm_inputs": [ci1],
"algorithm_outputs": [ci2],
},
)
assert form3.is_valid()
Loading

0 comments on commit 97d47e0

Please sign in to comment.