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
18 changes: 18 additions & 0 deletions backend/siarnaq/api/compete/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions backend/siarnaq/api/compete/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,7 +131,7 @@ class SubmissionViewSet(
permission_classes = (
IsAuthenticated,
IsEmailVerified,
IsEpisodeMutable | IsAdminUser,
IsEpisodeMutableForTeam | IsAdminUser,
IsOnTeam,
)
filter_backends = [IsSubmissionCreatorFilterBackend]
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -919,7 +919,7 @@ def get_permissions(self):
IsAuthenticated(),
IsEmailVerified(),
IsOnTeam(),
(IsEpisodeMutable | IsAdminUser)(),
(IsEpisodeMutableForTeam | IsAdminUser)(),
HasTeamSubmission(),
]
case "destroy":
Expand Down Expand Up @@ -1101,7 +1101,7 @@ def outbox(self, request, *, episode_id):
IsAuthenticated,
IsEmailVerified,
IsOnTeam,
IsEpisodeMutable | IsAdminUser,
IsEpisodeMutableForTeam | IsAdminUser,
HasTeamSubmission,
),
)
Expand Down
6 changes: 0 additions & 6 deletions backend/siarnaq/api/episodes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
63 changes: 62 additions & 1 deletion backend/siarnaq/api/episodes/permissions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
19 changes: 18 additions & 1 deletion backend/siarnaq/api/teams/serializers.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading