diff --git a/.env_sample b/.env_sample index 6bd01cfbd..3685e6277 100644 --- a/.env_sample +++ b/.env_sample @@ -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 diff --git a/src/apps/api/permissions.py b/src/apps/api/permissions.py index 3426a41f0..5cc64790b 100644 --- a/src/apps/api/permissions.py +++ b/src/apps/api/permissions.py @@ -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 diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 2c96e78b0..0934c5148 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -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') diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index b116184d7..588dcb8fb 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -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, @@ -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') diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 09bd4c474..0ed666929 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -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 @@ -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 @@ -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] diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index 0c95a8a3c..d37e845a3 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -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 @@ -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 diff --git a/src/apps/profiles/admin.py b/src/apps/profiles/admin.py index a3ad7bfe8..f43679deb 100644 --- a/src/apps/profiles/admin.py +++ b/src/apps/profiles/admin.py @@ -76,6 +76,7 @@ class UserExpansion(UserAdmin): list_filter = [ "is_staff", "is_superuser", + "can_create_competition", "is_deleted", "is_bot", "is_banned", @@ -88,6 +89,7 @@ class UserExpansion(UserAdmin): "quota", "is_staff", "is_superuser", + "can_create_competition", "is_banned", ] list_display_links = ["id", "username"] @@ -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", diff --git a/src/apps/profiles/migrations/0022_user_can_create_competition.py b/src/apps/profiles/migrations/0022_user_can_create_competition.py new file mode 100644 index 000000000..7f4cfe54e --- /dev/null +++ b/src/apps/profiles/migrations/0022_user_can_create_competition.py @@ -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, + ), + ), + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 042c7d0a1..c2b111a6a 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -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) diff --git a/src/settings/base.py b/src/settings/base.py index ed5b978d7..e4a29873d 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -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 diff --git a/src/static/riot/competitions/management.tag b/src/static/riot/competitions/management.tag index 1c065fc76..5e837aaef 100644 --- a/src/static/riot/competitions/management.tag +++ b/src/static/riot/competitions/management.tag @@ -2,10 +2,10 @@