From 701a298378f1af7df5dad4a20a5303ff84aa04a4 Mon Sep 17 00:00:00 2001 From: Vincent Zheng Date: Fri, 23 Oct 2026 21:10:10 -0400 Subject: [PATCH] Revert "Submission freeze limit to elig users (#964)" This reverts commit d281018eb9b029a809befba3cf9983b194f200bd. --- backend/siarnaq/api/compete/serializers.py | 18 -- backend/siarnaq/api/compete/views.py | 20 +- backend/siarnaq/api/episodes/models.py | 6 + backend/siarnaq/api/episodes/permissions.py | 63 +---- backend/siarnaq/api/teams/serializers.py | 19 +- backend/siarnaq/api/teams/tests.py | 252 +------------------- 6 files changed, 20 insertions(+), 358 deletions(-) diff --git a/backend/siarnaq/api/compete/serializers.py b/backend/siarnaq/api/compete/serializers.py index bd8971a34..85a6a4b3e 100644 --- a/backend/siarnaq/api/compete/serializers.py +++ b/backend/siarnaq/api/compete/serializers.py @@ -267,24 +267,6 @@ def get_replay_url(self, obj): def to_representation(self, instance): data = super().to_representation(instance) - # Normalize OrderedDicts to plain dicts for test compatibility - from collections import OrderedDict - - def _normalize(obj): - if isinstance(obj, OrderedDict): - return {k: _normalize(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_normalize(v) for v in obj] - return obj - - data = _normalize(data) - # Normalize created timestamp to UTC with 'Z' suffix - if "created" in data and data["created"]: - from django.utils import timezone as dj_tz - - # Parse the datetime string and convert to UTC - created = dj_tz.localtime(instance.created, dj_tz.utc) - data["created"] = created.isoformat().replace("+00:00", "Z") # Redact match details depending on client identity if self.context["user_is_staff"]: # Staff can see everything diff --git a/backend/siarnaq/api/compete/views.py b/backend/siarnaq/api/compete/views.py index 0fadab8b1..3c718d19b 100644 --- a/backend/siarnaq/api/compete/views.py +++ b/backend/siarnaq/api/compete/views.py @@ -47,7 +47,7 @@ TournamentSubmissionSerializer, ) from siarnaq.api.episodes.models import Episode, ReleaseStatus, Tournament -from siarnaq.api.episodes.permissions import IsEpisodeAvailable, IsEpisodeMutableForTeam +from siarnaq.api.episodes.permissions import IsEpisodeAvailable, IsEpisodeMutable from siarnaq.api.teams.models import Team, TeamStatus from siarnaq.api.teams.permissions import IsOnTeam from siarnaq.api.user.permissions import IsEmailVerified @@ -131,7 +131,7 @@ class SubmissionViewSet( permission_classes = ( IsAuthenticated, IsEmailVerified, - IsEpisodeMutableForTeam | IsAdminUser, + IsEpisodeMutable | IsAdminUser, IsOnTeam, ) filter_backends = [IsSubmissionCreatorFilterBackend] @@ -274,7 +274,7 @@ class MatchViewSet( """ serializer_class = MatchSerializer - permission_classes = (IsEpisodeMutableForTeam | IsAdminUser,) + permission_classes = (IsEpisodeMutable | IsAdminUser,) def get_queryset(self, prefetch_related=True): queryset = ( @@ -351,7 +351,7 @@ def get_serializer_context(self): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutableForTeam,), + permission_classes=(IsEpisodeMutable,), ) def tournament(self, request, *, episode_id): """ @@ -418,7 +418,7 @@ def tournament(self, request, *, episode_id): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutableForTeam,), + permission_classes=(IsEpisodeMutable,), ) def scrimmage(self, request, pk=None, *, episode_id): """List all scrimmages that a particular team participated in.""" @@ -510,7 +510,7 @@ def get_historical_rating(self, episode_id, teams): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutableForTeam,), + permission_classes=(IsEpisodeMutable,), pagination_class=None, ) def historical_rating(self, request, pk=None, *, episode_id): @@ -591,7 +591,7 @@ def historical_rating(self, request, pk=None, *, episode_id): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutableForTeam,), + permission_classes=(IsEpisodeMutable,), # needed so that the generated schema is not paginated pagination_class=None, ) @@ -644,7 +644,7 @@ def historical_rating_topN(self, request, pk=None, *, episode_id): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutableForTeam,), + permission_classes=(IsEpisodeMutable,), ) def scrimmaging_record(self, request, pk=None, *, episode_id): """ @@ -919,7 +919,7 @@ def get_permissions(self): IsAuthenticated(), IsEmailVerified(), IsOnTeam(), - (IsEpisodeMutableForTeam | IsAdminUser)(), + (IsEpisodeMutable | IsAdminUser)(), HasTeamSubmission(), ] case "destroy": @@ -1101,7 +1101,7 @@ def outbox(self, request, *, episode_id): IsAuthenticated, IsEmailVerified, IsOnTeam, - IsEpisodeMutableForTeam | IsAdminUser, + IsEpisodeMutable | IsAdminUser, HasTeamSubmission, ), ) diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 8da1be35a..ff4a6c580 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -132,6 +132,12 @@ def frozen(self): now = timezone.now() if self.submission_frozen or now < self.game_release: return True + return Tournament.objects.filter( + episode=self, + submission_freeze__lte=now, + submission_unfreeze__gt=now, + is_public=True, + ).exists() def autoscrim(self, best_of, override_freeze=False): """ diff --git a/backend/siarnaq/api/episodes/permissions.py b/backend/siarnaq/api/episodes/permissions.py index 5e1e7a7b4..f4ac58b51 100644 --- a/backend/siarnaq/api/episodes/permissions.py +++ b/backend/siarnaq/api/episodes/permissions.py @@ -1,9 +1,7 @@ -from django.apps import apps from django.shortcuts import get_object_or_404 -from django.utils import timezone from rest_framework import permissions -from siarnaq.api.episodes.models import Episode, Tournament +from siarnaq.api.episodes.models import Episode class IsEpisodeAvailable(permissions.BasePermission): @@ -19,65 +17,6 @@ def has_permission(self, request, view): return True -class IsEpisodeMutableForTeam(permissions.BasePermission): - """ - Allows mutation access to visible episodes iff the user is on a team that is not - currently frozen for a tournament with an active submission freeze window. - Episodes that are not visible will raise a 404. - """ - - def has_permission(self, request, view): - episode = get_object_or_404( - Episode.objects.visible_to_user(is_staff=request.user.is_staff), - pk=view.kwargs["episode_id"], - ) - # Allow safe methods (GET/HEAD/OPTIONS) without further checks. - if request.method in permissions.SAFE_METHODS: - return True - - # For mutating requests, require an authenticated user. - if not request.user or not request.user.is_authenticated: - return False - - # Find teams in this episode that the user belongs to. - Team = apps.get_model("teams", "Team") - team = Team.objects.filter(episode=episode, members=request.user) - - # If the user is not on any team in this episode, deny mutation. - if not team.exists(): - return False - - # If the episode itself is frozen (global freeze flag or pre-release), deny. - if episode.frozen(): - return False - - # Deny mutation if any of the user's teams are eligible for a tournament - # that currently has an active submission freeze window. - # Only consider tournaments with explicit eligibility criteria. - from django.db.models import Q - - now = timezone.now() - active_freeze_tournaments = ( - Tournament.objects.filter( - episode=episode, - submission_freeze__lte=now, - submission_unfreeze__gt=now, - is_public=True, - ) - .filter( - Q(eligibility_includes__isnull=False) - | Q(eligibility_excludes__isnull=False) - ) - .distinct() - ) - - for tournament in active_freeze_tournaments: - if team.filter_eligible(tournament).exists(): - return False - - return True - - class IsEpisodeMutable(permissions.BasePermission): """ Allows mutation access to visible episodes iff it is not frozen. Episodes that are diff --git a/backend/siarnaq/api/teams/serializers.py b/backend/siarnaq/api/teams/serializers.py index 0f49b4f0e..695a3fd7d 100644 --- a/backend/siarnaq/api/teams/serializers.py +++ b/backend/siarnaq/api/teams/serializers.py @@ -1,10 +1,9 @@ from django.db import transaction -from django.utils import timezone from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers, validators -from siarnaq.api.episodes.models import Episode, Tournament +from siarnaq.api.episodes.models import Episode from siarnaq.api.teams.models import ClassRequirement, Team, TeamProfile from siarnaq.api.user.serializers import UserPublicSerializer @@ -99,22 +98,6 @@ def update(self, instance, validated_data): instance, validated_data ) if eligible_for is not None: - # Prevent changing eligibility while any tournament in the team's - # episode is currently in its submission freeze window. This avoids - # teams toggling eligibility while tournaments are finalizing - # which could affect tournament entries. - now = timezone.now() - episode = instance.team.episode - active_freeze = Tournament.objects.filter( - episode=episode, - submission_freeze__lte=now, - submission_unfreeze__gt=now, - is_public=True, - ).exists() - if active_freeze: - raise serializers.ValidationError( - "Cannot change eligibility during tournament submission freeze." - ) instance.eligible_for.set(eligible_for) return instance diff --git a/backend/siarnaq/api/teams/tests.py b/backend/siarnaq/api/teams/tests.py index 1767ce5a6..09106d189 100644 --- a/backend/siarnaq/api/teams/tests.py +++ b/backend/siarnaq/api/teams/tests.py @@ -2,21 +2,14 @@ import unittest from unittest.mock import patch -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.urls import reverse from django.utils import timezone from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APITestCase from siarnaq.api.compete.models import Match, MatchParticipant, Submission -from siarnaq.api.episodes.models import ( - EligibilityCriterion, - Episode, - Language, - Map, - Tournament, -) +from siarnaq.api.episodes.models import EligibilityCriterion, Episode, Language, Map from siarnaq.api.teams.managers import generate_4regular_graph from siarnaq.api.teams.models import Team, TeamStatus from siarnaq.api.user.models import User @@ -247,109 +240,6 @@ def test_map_not_public(self): self.assertFalse(m.matches.exists()) -class TeamEligibilityFreezeTestCase(TestCase): - """Tests for blocking eligibility changes during tournament submission freeze.""" - - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="user", email="user@example.com", email_verified=True - ) - # Create a visible episode - self.episode = Episode.objects.create( - name_short="ep1", - registration=timezone.now(), - game_release=timezone.now(), - game_archive=timezone.now(), - submission_frozen=False, - language=Language.JAVA_8, - ) - # Create a team and add user - self.team = Team.objects.create(episode=self.episode, name="team1") - self.team.members.add(self.user) - - # Create an eligibility criterion linked to this episode - self.criterion = EligibilityCriterion.objects.create( - title="crit", - description="d", - icon="x", - is_private=False, - ) - self.episode.eligibility_criteria.add(self.criterion) - - def _submission_url(self): - return reverse( - "submission-list", kwargs={"episode_id": self.episode.name_short} - ) - - def _make_file(self): - return SimpleUploadedFile( - "source.zip", b"content", content_type="application/zip" - ) - - def _me_url(self): - return reverse("team-me", kwargs={"episode_id": self.episode.name_short}) - - def test_cannot_change_eligibility_during_freeze(self): - # Create an active-freeze tournament - now = timezone.now() - t1 = Tournament.objects.create( - name_short="t1", - name_long="Tournament 1", - episode=self.episode, - style="SE", - require_resume=False, - is_public=True, - display_date=now.date(), - submission_freeze=now - timezone.timedelta(hours=1), - submission_unfreeze=now + timezone.timedelta(hours=1), - ) - t1.eligibility_includes.add(self.criterion) - # make team eligible for criterion - self.team.profile.eligible_for.add(self.criterion) - - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - # Should be forbidden due to active freeze - self.assertEqual(resp.status_code, 403) - - def test_not_frozen_if_no_active_tournaments(self): - # No tournaments - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - self.assertEqual(resp.status_code, 201) - - def test_not_frozen_if_not_eligible_for_active_tournaments(self): - now = timezone.now() - # Create active tournament with eligibility criteria but team not eligible - t = Tournament.objects.create( - name_short="tC", - name_long="T C", - episode=self.episode, - style="SE", - require_resume=False, - is_public=True, - display_date=now.date(), - submission_freeze=now - timezone.timedelta(hours=1), - submission_unfreeze=now + timezone.timedelta(hours=1), - ) - # Add a different criterion so team is not eligible - other_criterion = EligibilityCriterion.objects.create( - title="other", description="d", icon="x" - ) - t.eligibility_includes.add(other_criterion) - - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - self.assertEqual(resp.status_code, 201) - - class EligibilityTestCase(APITestCase): """Test suite for team eligibility logic in Team API.""" @@ -368,17 +258,6 @@ def setUp(self): ) self.team.members.add(self.user) - self.criterion = EligibilityCriterion.objects.create( - title="crit-ep", - description="desc", - icon="i", - is_private=False, - ) - self.episode.eligibility_criteria.add(self.criterion) - - def _me_url(self): - return reverse("team-me", kwargs={"episode_id": self.episode.name_short}) - # Partitions for: me (patch) # eligible_for: contains private criteria, doesn't contain private criteria # eligible_for: criteria all in team's assigned episode, or not @@ -418,130 +297,3 @@ def test_patch_criterion_wrong_episode(self): format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - self.client.force_authenticate(self.user) - url = self._me_url() - data = {"profile": {"eligible_for": [self.criterion.pk]}} - resp = self.client.patch(url, data, format="json") - self.assertIn(resp.status_code, (status.HTTP_200_OK, status.HTTP_201_CREATED)) - self.team.refresh_from_db() - self.assertTrue( - self.team.profile.eligible_for.filter(pk=self.criterion.pk).exists() - ) - - def test_can_change_eligibility_outside_freeze(self): - # No active tournaments - self.client.force_authenticate(self.user) - url = self._me_url() - data = {"profile": {"eligible_for": [self.criterion.pk]}} - resp = self.client.patch(url, data, format="json") - self.assertIn(resp.status_code, (200, 201)) - # verify eligible_for was set - self.team.refresh_from_db() - self.assertTrue( - self.team.profile.eligible_for.filter(pk=self.criterion.pk).exists() - ) - - -class SubmissionFreezeBehaviorTestCase(TestCase): - """Tests submission create behavior under tournament submission freezes.""" - - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="user2", email="u2@example.com", email_verified=True - ) - self.episode = Episode.objects.create( - name_short="ep2", - registration=timezone.now(), - game_release=timezone.now(), - game_archive=timezone.now(), - submission_frozen=False, - language=Language.JAVA_8, - ) - self.team = Team.objects.create(episode=self.episode, name="teamA") - self.team.members.add(self.user) - self.criterion = EligibilityCriterion.objects.create( - title="crit2", description="d", icon="x" - ) - self.episode.eligibility_criteria.add(self.criterion) - - def _submission_url(self): - return reverse( - "submission-list", kwargs={"episode_id": self.episode.name_short} - ) - - def _make_file(self): - return SimpleUploadedFile( - "source.zip", b"content", content_type="application/zip" - ) - - def test_frozen_if_eligible_for_any_active_tournament(self): - now = timezone.now() - # Create two active tournaments; team eligible for tournament1 - t1 = Tournament.objects.create( - name_short="tA", - name_long="T A", - episode=self.episode, - style="SE", - require_resume=False, - is_public=True, - display_date=now.date(), - submission_freeze=now - timezone.timedelta(hours=1), - submission_unfreeze=now + timezone.timedelta(hours=1), - ) - Tournament.objects.create( - name_short="tB", - name_long="T B", - episode=self.episode, - style="SE", - require_resume=False, - is_public=True, - display_date=now.date(), - submission_freeze=now - timezone.timedelta(hours=1), - submission_unfreeze=now + timezone.timedelta(hours=1), - ) - t1.eligibility_includes.add(self.criterion) - # make team eligible for criterion - self.team.profile.eligible_for.add(self.criterion) - - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - # Should be forbidden due to active freeze - self.assertEqual(resp.status_code, 403) - - def test_not_frozen_if_no_active_tournaments(self): - # No tournaments - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - self.assertEqual(resp.status_code, 201) - - def test_not_frozen_if_not_eligible_for_active_tournaments(self): - now = timezone.now() - # Create active tournament with eligibility criteria but team not eligible - t = Tournament.objects.create( - name_short="tC", - name_long="T C", - episode=self.episode, - style="SE", - require_resume=False, - is_public=True, - display_date=now.date(), - submission_freeze=now - timezone.timedelta(hours=1), - submission_unfreeze=now + timezone.timedelta(hours=1), - ) - # Add a different criterion so team is not eligible - other_criterion = EligibilityCriterion.objects.create( - title="other", description="d", icon="x" - ) - t.eligibility_includes.add(other_criterion) - - self.client.force_authenticate(self.user) - url = self._submission_url() - data = {"source_code": self._make_file(), "package": "p", "description": "d"} - resp = self.client.post(url, data, format="multipart") - self.assertEqual(resp.status_code, 201)