Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ ALLOWED_HOSTS=localhost,example.com
SUBMISSIONS_API_URL=http://django:8000/api
MAX_EXECUTION_TIME_LIMIT=600 # time limit for the default queue (in seconds)

# Global default for competition creation permissions.
# If True, authenticated users can create competitions unless their user override says otherwise.
# If False, authenticated users need an explicit user override to create competitions.
COMPETITION_CREATION_ENABLED_BY_DEFAULT=True

# Local domain definition
DOMAIN_NAME=localhost:80

Expand Down
24 changes: 24 additions & 0 deletions src/apps/api/permissions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
from django.conf import settings
from rest_framework import permissions

from profiles.models import Membership


def competition_creation_enabled_by_default():
return getattr(settings, "COMPETITION_CREATION_ENABLED_BY_DEFAULT", True)


def user_can_create_competition(user):
if not user or not user.is_authenticated:
return False

if user.is_superuser:
return True

user_override = getattr(user, "can_create_competition", None)
if user_override is not None:
return user_override

return competition_creation_enabled_by_default()


class IsCompetitionCreator(permissions.BasePermission):
def has_permission(self, request, view):
return user_can_create_competition(request.user)


class IsOrganizerOrCollaborator(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
is_collab = request.user in obj.collaborators.all() if hasattr(obj, 'collaborators') else False
Expand Down
24 changes: 24 additions & 0 deletions src/apps/api/tests/test_competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@
from zipfile import ZipFile
from io import StringIO, BytesIO
from unittest import mock
from django.test import override_settings
from django.urls import reverse
from rest_framework.test import APITestCase

from api.permissions import user_can_create_competition
from api.serializers.competitions import CompetitionSerializer
from competitions.models import CompetitionParticipant, Submission, Competition
from factories import UserFactory, CompetitionFactory, CompetitionParticipantFactory, PhaseFactory, LeaderboardFactory, \
ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory


class CompetitionCreatePermissionTests(APITestCase):
@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=True)
def test_defaults_to_global_setting_when_user_has_no_override(self):
user = UserFactory(can_create_competition=None)
assert user_can_create_competition(user) is True

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=False)
def test_denies_when_global_default_is_disabled_and_user_has_no_override(self):
user = UserFactory(can_create_competition=None)
assert user_can_create_competition(user) is False

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=False)
def test_user_override_can_enable_creation_even_when_global_default_is_disabled(self):
user = UserFactory(can_create_competition=True)
assert user_can_create_competition(user) is True

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=True)
def test_user_override_can_disable_creation_even_when_global_default_is_enabled(self):
user = UserFactory(can_create_competition=False)
assert user_can_create_competition(user) is False


class CompetitionTests(APITestCase):
def setUp(self):
self.creator = UserFactory(username='creator', password='creator')
Expand Down
46 changes: 46 additions & 0 deletions src/apps/api/tests/test_datasets.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from django.urls import reverse
from faker import Factory
from django.test import override_settings
from django.test import TestCase
from rest_framework.test import APITestCase
from datasets.models import Data
from competitions.models import CompetitionCreationTaskStatus
from factories import (
UserFactory,
DataFactory,
Expand Down Expand Up @@ -212,6 +214,50 @@ def test_download_nonexistent_dataset(self):
self.assertEqual(response.status_code, 404)


class DatasetUploadCompletedPermissionTests(APITestCase):
def setUp(self):
self.user = UserFactory()
self.client.force_login(self.user)
self.dataset = DataFactory(created_by=self.user, type=Data.COMPETITION_BUNDLE)
self.url = f"/api/datasets/completed/{self.dataset.key}/"

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=False)
@patch("competitions.tasks.unpack_competition.apply_async")
def test_upload_completed_forbidden_when_global_default_is_disabled(self, mock_apply_async):
response = self.client.put(self.url)

self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["detail"], "You do not have permission to create competitions")
self.assertEqual(CompetitionCreationTaskStatus.objects.count(), 0)
mock_apply_async.assert_not_called()

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=False)
@patch("competitions.tasks.unpack_competition.apply_async")
def test_upload_completed_allowed_when_user_override_is_enabled(self, mock_apply_async):
self.user.can_create_competition = True
self.user.save(update_fields=["can_create_competition"])

response = self.client.put(self.url)

self.assertEqual(response.status_code, 200)
self.assertIn("status_id", response.data)
self.assertEqual(CompetitionCreationTaskStatus.objects.count(), 1)
mock_apply_async.assert_called_once()

@override_settings(COMPETITION_CREATION_ENABLED_BY_DEFAULT=True)
@patch("competitions.tasks.unpack_competition.apply_async")
def test_upload_completed_forbidden_when_user_override_is_disabled(self, mock_apply_async):
self.user.can_create_competition = False
self.user.save(update_fields=["can_create_competition"])

response = self.client.put(self.url)

self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["detail"], "You do not have permission to create competitions")
self.assertEqual(CompetitionCreationTaskStatus.objects.count(), 0)
mock_apply_async.assert_not_called()


class DatasetCreateTests(APITestCase):
def setUp(self):
self.user = UserFactory(username='creator', password='creator')
Expand Down
6 changes: 3 additions & 3 deletions src/apps/api/views/competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.filters import SearchFilter
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
from rest_framework_csv.renderers import CSVRenderer
Expand All @@ -33,7 +33,7 @@
from competitions.utils import get_popular_competitions, get_recent_competitions
from leaderboards.models import Leaderboard
from utils.data import make_url_sassy
from api.permissions import IsOrganizerOrCollaborator
from api.permissions import IsOrganizerOrCollaborator, IsCompetitionCreator
from django.db import transaction
from django.conf import settings

Expand Down Expand Up @@ -186,7 +186,7 @@ def get_permissions(self):
if self.action in ['update', 'partial_update', 'destroy']:
self.permission_classes = [IsOrganizerOrCollaborator]
elif self.action in ['create']:
self.permission_classes = [IsAuthenticated]
self.permission_classes = [IsCompetitionCreator]
elif self.action in ['retrieve', 'list']:
self.permission_classes = [AllowAny]
return [permission() for permission in self.permission_classes]
Expand Down
5 changes: 5 additions & 0 deletions src/apps/api/views/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status
from rest_framework.decorators import api_view, action
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import AllowAny

from api.permissions import user_can_create_competition
from api.pagination import BasicPagination, LargePagination
from api.serializers import datasets as serializers
from datasets.models import Data
Expand Down Expand Up @@ -266,6 +268,9 @@ def upload_completed(request, key):
dataset.save()

if dataset.type == Data.COMPETITION_BUNDLE:
if not user_can_create_competition(request.user):
raise PermissionDenied("You do not have permission to create competitions")

# Doing a local import here to avoid circular imports
from competitions.tasks import unpack_competition

Expand Down
3 changes: 3 additions & 0 deletions src/apps/profiles/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class UserExpansion(UserAdmin):
list_filter = [
"is_staff",
"is_superuser",
"can_create_competition",
"is_deleted",
"is_bot",
"is_banned",
Expand All @@ -88,6 +89,7 @@ class UserExpansion(UserAdmin):
"quota",
"is_staff",
"is_superuser",
"can_create_competition",
"is_banned",
]
list_display_links = ["id", "username"]
Expand All @@ -114,6 +116,7 @@ class UserExpansion(UserAdmin):
"fields": [
("is_active", "is_bot"),
(
"can_create_competition",
"organizer_direct_message_updates",
"allow_forum_notifications",
"allow_organization_invite_emails",
Expand Down
23 changes: 23 additions & 0 deletions src/apps/profiles/migrations/0022_user_can_create_competition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.25 on 2026-05-21 00:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("profiles", "0021_merge_0020_alter_user_quota_0020_merge_20251212_0942"),
]

operations = [
migrations.AddField(
model_name="user",
name="can_create_competition",
field=models.BooleanField(
blank=True,
default=None,
help_text="Leave blank to inherit the global competition creation default.",
null=True,
),
),
]
6 changes: 6 additions & 0 deletions src/apps/profiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ class User(AbstractBaseUser, PermissionsMixin):
organizer_direct_message_updates = models.BooleanField(default=True)
allow_forum_notifications = models.BooleanField(default=True)
allow_organization_invite_emails = models.BooleanField(default=True)
can_create_competition = models.BooleanField(
null=True,
blank=True,
default=None,
help_text="Leave blank to inherit the global competition creation default.",
)

# Queues
rabbitmq_queue_limit = models.PositiveIntegerField(default=5, blank=True)
Expand Down
4 changes: 4 additions & 0 deletions src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@
# User Models
AUTH_USER_MODEL = 'profiles.User'
SOCIAL_AUTH_USER_MODEL = 'profiles.User'
COMPETITION_CREATION_ENABLED_BY_DEFAULT = os.environ.get(
'COMPETITION_CREATION_ENABLED_BY_DEFAULT',
'True',
).lower() == 'true'

# =============================================================================
# Debugging
Expand Down
4 changes: 2 additions & 2 deletions src/static/riot/competitions/management.tag
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
<div class="ui center aligned grid">
<div class="fourteen wide column">
<h1 style="float: left; display: inline-block;">Benchmark Management</h1>
<a class="ui right floated green button" href="{ URLS.COMPETITION_UPLOAD }">
<a if="{ CODALAB.state.user.can_create_competition }" class="ui right floated green button" href="{ URLS.COMPETITION_UPLOAD }">
<i class="upload icon"></i> Upload
</a>
<a class="ui right floated green button" href="{ URLS.COMPETITION_ADD }">
<a if="{ CODALAB.state.user.can_create_competition }" class="ui right floated green button" href="{ URLS.COMPETITION_ADD }">
<i class="add square icon"></i> Create
</a>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/static/riot/competitions/public-list.tag
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<!-- Title -->
<div class="page-header">
<h1 class="page-title">Public Benchmarks and Competitions</h1>
<div class="action-buttons">
<div class="action-buttons" if="{ CODALAB.state.user.can_create_competition }">
<a class="create-btn" href="{ URLS.COMPETITION_ADD }">
<i class="bi bi-plus-square-fill me-1"></i> Create
</a>
Expand Down
4 changes: 3 additions & 1 deletion src/utils/context_processors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os
from django.conf import settings
from api.permissions import user_can_create_competition

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Set the absolute path for the version file
Expand All @@ -19,10 +20,11 @@ def common_settings(request):
"bio": request.user.bio,
"is_staff": request.user.is_staff,
"is_superuser": request.user.is_superuser,
"can_create_competition": user_can_create_competition(request.user),
"logged_in": request.user.is_authenticated,
}
else:
user_json_data = {"logged_in": False}
user_json_data = {"logged_in": False, "can_create_competition": False}

# Read version information from the version.json file
version_info = {}
Expand Down