diff --git a/backend/siarnaq/api/compete/serializers.py b/backend/siarnaq/api/compete/serializers.py index 85a6a4b3e..bd8971a34 100644 --- a/backend/siarnaq/api/compete/serializers.py +++ b/backend/siarnaq/api/compete/serializers.py @@ -267,6 +267,24 @@ 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 3c718d19b..0fadab8b1 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, IsEpisodeMutable +from siarnaq.api.episodes.permissions import IsEpisodeAvailable, IsEpisodeMutableForTeam 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, - IsEpisodeMutable | IsAdminUser, + IsEpisodeMutableForTeam | IsAdminUser, IsOnTeam, ) filter_backends = [IsSubmissionCreatorFilterBackend] @@ -274,7 +274,7 @@ class MatchViewSet( """ serializer_class = MatchSerializer - permission_classes = (IsEpisodeMutable | IsAdminUser,) + permission_classes = (IsEpisodeMutableForTeam | 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=(IsEpisodeMutable,), + permission_classes=(IsEpisodeMutableForTeam,), ) def tournament(self, request, *, episode_id): """ @@ -418,7 +418,7 @@ def tournament(self, request, *, episode_id): @action( detail=False, methods=["get"], - permission_classes=(IsEpisodeMutable,), + permission_classes=(IsEpisodeMutableForTeam,), ) 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=(IsEpisodeMutable,), + permission_classes=(IsEpisodeMutableForTeam,), 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=(IsEpisodeMutable,), + permission_classes=(IsEpisodeMutableForTeam,), # 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=(IsEpisodeMutable,), + permission_classes=(IsEpisodeMutableForTeam,), ) def scrimmaging_record(self, request, pk=None, *, episode_id): """ @@ -919,7 +919,7 @@ def get_permissions(self): IsAuthenticated(), IsEmailVerified(), IsOnTeam(), - (IsEpisodeMutable | IsAdminUser)(), + (IsEpisodeMutableForTeam | IsAdminUser)(), HasTeamSubmission(), ] case "destroy": @@ -1101,7 +1101,7 @@ def outbox(self, request, *, episode_id): IsAuthenticated, IsEmailVerified, IsOnTeam, - IsEpisodeMutable | IsAdminUser, + IsEpisodeMutableForTeam | IsAdminUser, HasTeamSubmission, ), ) diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index ff4a6c580..8da1be35a 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -132,12 +132,6 @@ 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 f4ac58b51..5e1e7a7b4 100644 --- a/backend/siarnaq/api/episodes/permissions.py +++ b/backend/siarnaq/api/episodes/permissions.py @@ -1,7 +1,9 @@ +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 +from siarnaq.api.episodes.models import Episode, Tournament class IsEpisodeAvailable(permissions.BasePermission): @@ -17,6 +19,65 @@ 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 695a3fd7d..0f49b4f0e 100644 --- a/backend/siarnaq/api/teams/serializers.py +++ b/backend/siarnaq/api/teams/serializers.py @@ -1,9 +1,10 @@ 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 +from siarnaq.api.episodes.models import Episode, Tournament from siarnaq.api.teams.models import ClassRequirement, Team, TeamProfile from siarnaq.api.user.serializers import UserPublicSerializer @@ -98,6 +99,22 @@ 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 09106d189..1767ce5a6 100644 --- a/backend/siarnaq/api/teams/tests.py +++ b/backend/siarnaq/api/teams/tests.py @@ -2,14 +2,21 @@ 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 APITestCase +from rest_framework.test import APIClient, APITestCase from siarnaq.api.compete.models import Match, MatchParticipant, Submission -from siarnaq.api.episodes.models import EligibilityCriterion, Episode, Language, Map +from siarnaq.api.episodes.models import ( + EligibilityCriterion, + Episode, + Language, + Map, + Tournament, +) from siarnaq.api.teams.managers import generate_4regular_graph from siarnaq.api.teams.models import Team, TeamStatus from siarnaq.api.user.models import User @@ -240,6 +247,109 @@ 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.""" @@ -258,6 +368,17 @@ 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 @@ -297,3 +418,130 @@ 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)