Skip to content

Commit

Permalink
Add Public Challenges API (#3082)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsmkn authored Nov 6, 2023
1 parent 756dcd4 commit a28637b
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 15 deletions.
4 changes: 4 additions & 0 deletions app/grandchallenge/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ImageViewSet,
RawImageUploadSessionViewSet,
)
from grandchallenge.challenges.views import ChallengeViewSet
from grandchallenge.components.views import ComponentInterfaceViewSet
from grandchallenge.evaluation.views.api import EvaluationViewSet
from grandchallenge.github.views import github_webhook
Expand Down Expand Up @@ -74,6 +75,9 @@
basename="upload-session",
)

# Challenges
router.register(r"challenges", ChallengeViewSet, basename="challenge")

# Component Interfaces
router.register(
r"components/interfaces",
Expand Down
43 changes: 37 additions & 6 deletions app/grandchallenge/challenges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from django.utils.translation import gettext_lazy as _
from django_deprecate_fields import deprecate_field
from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase
from guardian.shortcuts import assign_perm
from guardian.shortcuts import assign_perm, remove_perm
from guardian.utils import get_anonymous_user
from machina.apps.forum.models import Forum
from machina.apps.forum_permission.models import (
Expand Down Expand Up @@ -391,7 +391,7 @@ def __str__(self):
return self.short_name

@property
def public(self):
def public(self) -> bool:
"""Helper property for consistency with other objects"""
return not self.hidden

Expand All @@ -407,6 +407,14 @@ def upcoming_workshop_date(self):
if self.workshop_date and self.workshop_date > datetime.date.today():
return self.workshop_date

@property
def slug(self) -> str:
return self.short_name

@property
def api_url(self) -> str:
return reverse("api:challenge-detail", kwargs={"slug": self.slug})

def save(self, *args, **kwargs):
adding = self._state.adding

Expand All @@ -416,10 +424,11 @@ def save(self, *args, **kwargs):

super().save(*args, **kwargs)

self.assign_permissions()

if adding:
if self.creator:
self.add_admin(user=self.creator)
self.update_permissions()
self.create_forum_permissions()
self.create_default_pages()

Expand All @@ -435,9 +444,23 @@ def save(self, *args, **kwargs):
)
self.update_user_forum_permissions()

def update_permissions(self):
def assign_permissions(self):
# Editors and users can view this algorithm
assign_perm("view_challenge", self.admins_group, self)
assign_perm("view_challenge", self.participants_group, self)

# Admins can change this challenge
assign_perm("change_challenge", self.admins_group, self)

reg_and_anon = Group.objects.get(
name=settings.REGISTERED_AND_ANON_USERS_GROUP_NAME
)

if self.public:
assign_perm("view_challenge", reg_and_anon, self)
else:
remove_perm("view_challenge", reg_and_anon, self)

def create_forum_permissions(self):
participant_group_perms = {
"can_see_forum",
Expand Down Expand Up @@ -613,7 +636,7 @@ def remove_admin(self, user):
unfollow(user=user, obj=self.forum, send_action=False)

@cached_property
def status(self):
def status(self) -> str:
phase_status = {phase.status for phase in self.phase_set.all()}
if StatusChoices.OPEN in phase_status:
status = StatusChoices.OPEN
Expand Down Expand Up @@ -693,7 +716,15 @@ def status_badge_string(self):

@cached_property
def visible_phases(self):
return self.phase_set.filter(public=True)
# For use in list views where the phases have been prefetched
return [phase for phase in self.phase_set.all() if phase.public]

@cached_property
def first_visible_phase(self):
try:
return self.visible_phases[0]
except IndexError:
return None


class ChallengeUserObjectPermission(UserObjectPermissionBase):
Expand Down
80 changes: 80 additions & 0 deletions app/grandchallenge/challenges/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from datetime import datetime

from rest_framework import serializers
from rest_framework.fields import SerializerMethodField, URLField

from grandchallenge.challenges.models import Challenge


class PublicChallengeSerializer(serializers.ModelSerializer):
url = URLField(source="get_absolute_url", read_only=True)
publications = SerializerMethodField()
submission_types = SerializerMethodField()
start_date = SerializerMethodField()
end_date = SerializerMethodField()
modified = SerializerMethodField()

class Meta:
model = Challenge
fields = [
"api_url",
"url",
"slug",
"title",
"description",
"public",
"status",
"logo",
"submission_types",
"start_date",
"end_date",
"publications",
"created",
"modified",
]

def get_publications(self, obj) -> list[str]:
return [p.identifier for p in obj.publications.all()]

def get_start_date(self, obj) -> datetime | None:
try:
return min(
p.submissions_open_at
for p in obj.visible_phases
if p.submissions_open_at
)
except ValueError:
# No submission open set
return None

def get_end_date(self, obj) -> datetime | None:
if any(p.submissions_close_at is None for p in obj.visible_phases):
return None
else:
try:
return max(
p.submissions_close_at
for p in obj.visible_phases
if p.submissions_close_at
)
except ValueError:
# No Phases
return None

def get_submission_types(self, obj) -> list[str]:
return list(
{
phase.get_submission_kind_display()
for phase in obj.visible_phases
}
)

def get_modified(self, obj) -> datetime:
try:
return max(
obj.modified,
max(p.modified for p in obj.visible_phases if p.modified),
)
except ValueError:
# No Phases
return obj.modified
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,28 @@
href="{% url 'evaluation:leaderboard' challenge_short_name=challenge.short_name slug=challenge.phase_set.first.slug %}">
<i class="fas fa-trophy fa-fw"></i>&nbsp;&nbsp;Leaderboard{{ challenge.phase_set.all|pluralize }}</a>
</li>
{% elif user_is_participant and challenge.visible_phases.first %}
{% elif user_is_participant and challenge.first_visible_phase %}
{% if challenge.use_workspaces %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.app_name == "workspaces" %}active{% endif %}"
href="{% url 'workspaces:create' challenge_short_name=challenge.short_name slug=challenge.visible_phases.first.slug %}">
href="{% url 'workspaces:create' challenge_short_name=challenge.short_name slug=challenge.first_visible_phase.slug %}">
<i class="fas fa-tools fa-fw"></i>&nbsp;Workspaces</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'evaluation:submission-create' or request.resolver_match.view_name == 'evaluation:submission-list' or request.resolver_match.view_name == 'evaluation:submission-detail' %}active{% endif %}"
href="{% url 'evaluation:submission-create' challenge_short_name=challenge.short_name slug=challenge.visible_phases.first.slug %}">
href="{% url 'evaluation:submission-create' challenge_short_name=challenge.short_name slug=challenge.first_visible_phase.slug %}">
<i class="fas fa-upload fa-fw"></i>&nbsp;&nbsp;Submit</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'evaluation:leaderboard' or request.resolver_match.view_name == 'evaluation:list' or request.resolver_match.view_name == 'evaluation:detail' or request.resolver_match.view_name == 'evaluation:update' %}active{% endif %}"
href="{% url 'evaluation:leaderboard' challenge_short_name=challenge.short_name slug=challenge.visible_phases.first.slug %}">
href="{% url 'evaluation:leaderboard' challenge_short_name=challenge.short_name slug=challenge.first_visible_phase.slug %}">
<i class="fas fa-trophy fa-fw"></i>&nbsp;&nbsp;Leaderboard{{ challenge.phase_set.all|pluralize }}</a>
</li>
{% elif not challenge.hidden and challenge.visible_phases.first %}
{% elif not challenge.hidden and challenge.first_visible_phase %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.view_name == 'evaluation:leaderboard' or request.resolver_match.view_name == 'evaluation:list' or request.resolver_match.view_name == 'evaluation:detail' or request.resolver_match.view_name == 'evaluation:update' %}active{% endif %}"
href="{% url 'evaluation:leaderboard' challenge_short_name=challenge.short_name slug=challenge.visible_phases.first.slug %}">
href="{% url 'evaluation:leaderboard' challenge_short_name=challenge.short_name slug=challenge.first_visible_phase.slug %}">
<i class="fas fa-trophy fa-fw"></i>&nbsp;&nbsp;Leaderboard{{ challenge.phase_set.all|pluralize }}</a>
</li>
{% endif %}
Expand Down
16 changes: 16 additions & 0 deletions app/grandchallenge/challenges/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
UpdateView,
)
from guardian.mixins import LoginRequiredMixin
from rest_framework.permissions import DjangoObjectPermissions
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter

from grandchallenge.challenges.emails import send_challenge_status_update_email
from grandchallenge.challenges.filters import ChallengeFilter
Expand All @@ -22,6 +25,7 @@
ChallengeUpdateForm,
)
from grandchallenge.challenges.models import Challenge, ChallengeRequest
from grandchallenge.challenges.serializers import PublicChallengeSerializer
from grandchallenge.core.filters import FilterMixin
from grandchallenge.core.guardian import ObjectPermissionRequiredMixin
from grandchallenge.datatables.views import Column, PaginatedTableListView
Expand Down Expand Up @@ -327,3 +331,15 @@ def get(self, request, *args, **kwargs):
template=self.template_name,
context=context,
)


class ChallengeViewSet(ReadOnlyModelViewSet):
queryset = Challenge.objects.all().prefetch_related(
"publications", "phase_set"
)
serializer_class = PublicChallengeSerializer
permission_classes = [DjangoObjectPermissions]
filter_backends = [ObjectPermissionsFilter]
# We do not want to serialize the pk so lookup by short_name, but call it slug
lookup_field = "short_name"
lookup_url_kwarg = "slug"
2 changes: 1 addition & 1 deletion app/grandchallenge/evaluation/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ class Migration(migrations.Migration):
(
"submission_kind",
models.PositiveSmallIntegerField(
choices=[(1, "CSV"), (2, "ZIP"), (3, "Algorithm")],
choices=[(1, "Predictions"), (3, "Algorithm")],
default=1,
help_text="Should participants submit a .csv/.zip file of predictions, or an algorithm?",
),
Expand Down
4 changes: 2 additions & 2 deletions app/grandchallenge/evaluation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,6 @@ class StatusChoices(models.TextChoices):


class SubmissionKindChoices(models.IntegerChoices):
CSV = 1, "CSV"
ZIP = 2, "ZIP"
CSV = 1, "Predictions"
# ZIP = 2, "ZIP"
ALGORITHM = 3, "Algorithm"

0 comments on commit a28637b

Please sign in to comment.