Skip to content

Commit

Permalink
Add grand-challenge-forge JSON descriptors (#3143)
Browse files Browse the repository at this point in the history
Part of the pitch:
- DIAGNijmegen/rse-roadmap#237

This adds fields to the admin pages for `Phase` and `Challenge`.

The value of the fields can be used as input for
https://github.com/DIAGNijmegen/rse-grand-challenge-forge

---------

Co-authored-by: James Meakin <[email protected]>
  • Loading branch information
chrisvanrun and jmsmkn authored Dec 19, 2023
1 parent 2f4f569 commit 6072917
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 2 deletions.
18 changes: 17 additions & 1 deletion app/grandchallenge/challenges/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import json

from django.contrib import admin, messages
from django.contrib.admin import ModelAdmin
from django.core.exceptions import ValidationError
from django.utils.html import format_html

from grandchallenge.challenges.emails import send_challenge_status_update_email
from grandchallenge.challenges.models import (
Expand All @@ -15,11 +18,17 @@
UserObjectPermissionAdmin,
)
from grandchallenge.core.templatetags.costs import millicents_to_euro
from grandchallenge.core.utils.grand_challenge_forge import (
get_forge_json_description,
)


@admin.register(Challenge)
class ChallengeAdmin(ModelAdmin):
readonly_fields = ("creator",)
readonly_fields = (
"creator",
"challenge_forge_json",
)
autocomplete_fields = ("publications",)
ordering = ("-created",)
list_display = (
Expand All @@ -40,6 +49,13 @@ def get_queryset(self, *args, **kwargs):
def available_compute_euros(self, obj):
return millicents_to_euro(obj.available_compute_euro_millicents)

@staticmethod
def challenge_forge_json(obj):
json_desc = get_forge_json_description(challenge=obj)
return format_html(
"<pre>{json_desc}</pre>", json_desc=json.dumps(json_desc, indent=2)
)


@admin.register(ChallengeRequest)
class ChallengeRequestAdmin(ModelAdmin):
Expand Down
53 changes: 53 additions & 0 deletions app/grandchallenge/core/utils/grand_challenge_forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from grandchallenge.evaluation.utils import SubmissionKindChoices
from grandchallenge.subdomains.utils import reverse


def get_forge_json_description(challenge, phase_pks=None):
"""
Generates a JSON description of the challenge and phases suitable for
grand-challenge-forge to generate a challenge pack.
"""

phases = challenge.phase_set.filter(
archive__isnull=False,
submission_kind=SubmissionKindChoices.ALGORITHM,
).prefetch_related("archive", "inputs", "outputs")

if phase_pks is not None:
phases = phases.filter(pk__in=phase_pks)

archives = {p.archive for p in phases}

def process_archive(archive):
return {
"slug": archive.slug,
"url": reverse("archives:detail", kwargs={"slug": archive.slug}),
}

def process_component_interface(component_interface):
return {
"slug": component_interface.slug,
"kind": component_interface.get_kind_display(),
"super_kind": component_interface.super_kind.label,
"relative_path": component_interface.relative_path,
}

def process_phase(phase):
return {
"slug": phase.slug,
"archive": process_archive(phase.archive),
"inputs": [
process_component_interface(ci) for ci in phase.inputs.all()
],
"outputs": [
process_component_interface(ci) for ci in phase.outputs.all()
],
}

return {
"challenge": {
"slug": challenge.slug,
"phases": [process_phase(p) for p in phases],
"archives": [process_archive(a) for a in archives],
}
}
21 changes: 20 additions & 1 deletion app/grandchallenge/evaluation/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import json

from django.contrib import admin
from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.utils.html import format_html

from grandchallenge.components.admin import (
ComponentImageAdmin,
Expand All @@ -13,6 +16,9 @@
UserObjectPermissionAdmin,
)
from grandchallenge.core.templatetags.remove_whitespace import oxford_comma
from grandchallenge.core.utils.grand_challenge_forge import (
get_forge_json_description,
)
from grandchallenge.evaluation.models import (
CombinedLeaderboard,
Evaluation,
Expand Down Expand Up @@ -78,13 +84,26 @@ class PhaseAdmin(admin.ModelAdmin):
"algorithm_outputs",
"archive",
)
readonly_fields = ("give_algorithm_editors_job_view_permissions",)
readonly_fields = (
"give_algorithm_editors_job_view_permissions",
"challenge_forge_json",
)
form = PhaseAdminForm

@admin.display(boolean=True)
def open_for_submissions(self, instance):
return instance.open_for_submissions

@staticmethod
def challenge_forge_json(obj):
json_desc = get_forge_json_description(
challenge=obj.challenge,
phase_pks=[obj.pk],
)
return format_html(
"<pre>{json_desc}</pre>", json_desc=json.dumps(json_desc, indent=2)
)


@admin.action(
description="Reevaluate selected submissions",
Expand Down
72 changes: 72 additions & 0 deletions app/tests/core_tests/test_grand_challenge_forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import pytest

from grandchallenge.core.utils.grand_challenge_forge import (
get_forge_json_description,
)
from grandchallenge.evaluation.utils import SubmissionKindChoices
from tests.archives_tests.factories import ArchiveFactory
from tests.components_tests.factories import ComponentInterfaceFactory
from tests.evaluation_tests.factories import PhaseFactory
from tests.factories import ChallengeFactory


@pytest.mark.django_db
def test_get_forge_json_description():
challenge = ChallengeFactory()
inputs = [
ComponentInterfaceFactory(),
ComponentInterfaceFactory(),
]
outputs = [
ComponentInterfaceFactory(),
ComponentInterfaceFactory(),
]
archive = ArchiveFactory()
phase_1 = PhaseFactory(
challenge=challenge,
archive=archive,
submission_kind=SubmissionKindChoices.ALGORITHM,
)
phase_2 = PhaseFactory(
challenge=challenge,
archive=archive,
submission_kind=SubmissionKindChoices.ALGORITHM,
)
for phase in phase_1, phase_2:
phase.algorithm_inputs.set(inputs)
phase.algorithm_outputs.set(outputs)

# Setup phases that should not pass the filters
phase_3 = PhaseFactory(
challenge=challenge,
archive=None, # Hence should not be included
submission_kind=SubmissionKindChoices.ALGORITHM,
)
PhaseFactory(
challenge=challenge,
submission_kind=SubmissionKindChoices.CSV, # Hence should not be included
)

description = get_forge_json_description(challenge)
assert description["challenge"]["slug"] == challenge.slug

assert len(description["challenge"]["archives"]) == 1
for key in ["slug", "url"]:
assert key in description["challenge"]["archives"][0]

assert len(description["challenge"]["phases"]) == 2
for phase in description["challenge"]["phases"]:
for phase_key in ["slug", "archive", "inputs", "outputs"]:
assert phase_key in phase
for ci_key in ["slug", "kind", "super_kind", "relative_path"]:
for component_interface in [
*phase["inputs"],
*phase["outputs"],
]:
assert ci_key in component_interface

description = get_forge_json_description(challenge, phase_pks=[phase_1.pk])
assert len(description["challenge"]["phases"]) == 1

description = get_forge_json_description(challenge, phase_pks=[phase_3.pk])
assert len(description["challenge"]["phases"]) == 0

0 comments on commit 6072917

Please sign in to comment.