Skip to content
Merged
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
55 changes: 46 additions & 9 deletions backend/siarnaq/api/compete/test_views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import io
import random
from datetime import timedelta
import re
from collections import OrderedDict
from datetime import datetime, timedelta
from datetime import timezone as dt_timezone
from typing import Any
from unittest.mock import mock_open, patch

from django.test import TestCase, override_settings
Expand Down Expand Up @@ -37,6 +41,39 @@
from siarnaq.api.user.models import User


def ordered_dict_to_dict(data: Any) -> Any:
"""
Recursively clean data for test comparisons.
This function:
- Converts OrderedDict to dict recursively
- Normalizes ISO 8601 datetime strings to UTC (Z suffix)
"""
if isinstance(data, OrderedDict):
return {key: ordered_dict_to_dict(value) for key, value in data.items()}
elif isinstance(data, dict):
return {key: ordered_dict_to_dict(value) for key, value in data.items()}
elif isinstance(data, list):
return [ordered_dict_to_dict(item) for item in data]
elif isinstance(data, str):
# Check if it's an ISO 8601 datetime string and normalize to UTC
iso_pattern = (
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?([+-]\d{2}:\d{2}|Z)$"
)
if re.match(iso_pattern, data):
try:
# Parse the datetime and convert to UTC with Z suffix
dt = datetime.fromisoformat(data.replace("Z", "+00:00"))
# Convert to UTC and format with Z suffix
utc_dt = dt.astimezone(dt_timezone.utc)
return utc_dt.isoformat().replace("+00:00", "Z")
except (ValueError, AttributeError):
# If parsing fails, return as-is
return data
return data
else:
return data


class SubmissionViewSetTestCase(APITestCase):
"""Test suite for the Submissions API."""

Expand Down Expand Up @@ -480,8 +517,8 @@ def test_not_admin_has_self_has_staff_team(self):
)
match.maps.add(self.map)
data = serializer.to_representation(match)
self.assertEqual(
data,
self.assertDictEqual(
ordered_dict_to_dict(data),
{
"id": match.pk,
"status": str(match.status),
Expand Down Expand Up @@ -711,8 +748,8 @@ def test_not_admin_has_self_no_staff_team_tournament_none(self):
)
match.maps.add(self.map)
data = serializer.to_representation(match)
self.assertEqual(
data,
self.assertDictEqual(
ordered_dict_to_dict(data),
{
"id": match.pk,
"status": str(match.status),
Expand Down Expand Up @@ -782,8 +819,8 @@ def test_not_admin_no_self_has_staff_team(self):
)
match.maps.add(self.map)
data = serializer.to_representation(match)
self.assertEqual(
data,
self.assertDictEqual(
ordered_dict_to_dict(data),
{
"id": match.pk,
"status": str(match.status),
Expand Down Expand Up @@ -853,8 +890,8 @@ def test_not_admin_no_self_no_staff_team(self):
)
match.maps.add(self.map)
data = serializer.to_representation(match)
self.assertEqual(
data,
self.assertDictEqual(
ordered_dict_to_dict(data),
{
"id": match.pk,
"status": str(match.status),
Expand Down
6 changes: 3 additions & 3 deletions backend/siarnaq/api/episodes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ class MapAdmin(admin.ModelAdmin):

@admin.register(EligibilityCriterion)
class EligibilityCriterionAdmin(admin.ModelAdmin):
fields = ("title", "description", "icon")
list_display = ("title", "icon")
ordering = ("title",)
fields = ("title", "description", "icon", "is_private")
list_display = ("title", "icon", "is_private")
ordering = ("is_private", "title")
search_fields = ("title",)
search_help_text = "Search for a title."

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.1.2 on 2025-11-04 21:18

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("episodes", "0011_episode_ranked_scrimmage_hourly_limit_and_more"),
]

operations = [
migrations.AddField(
model_name="eligibilitycriterion",
name="is_private",
field=models.BooleanField(default=True),
),
]
3 changes: 3 additions & 0 deletions backend/siarnaq/api/episodes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class EligibilityCriterion(models.Model):
icon = models.CharField(max_length=8)
"""An icon to display for teams that satisfy this criterion."""

is_private = models.BooleanField(default=True)
"""Whether this criterion can only be assigned by an admin."""

def __str__(self):
return self.title

Expand Down
62 changes: 61 additions & 1 deletion backend/siarnaq/api/teams/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from unittest.mock import patch

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 siarnaq.api.compete.models import Match, MatchParticipant, Submission
from siarnaq.api.episodes.models import Episode, Language, Map
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
Expand Down Expand Up @@ -235,3 +238,60 @@ def test_map_not_public(self):
Team.objects.autoscrim(episode=e1, best_of=3)
m.refresh_from_db()
self.assertFalse(m.matches.exists())


class EligibilityTestCase(APITestCase):
"""Test suite for team eligibility logic in Team API."""

def setUp(self):
self.episode = Episode.objects.create(
name_short="ep",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
language=Language.JAVA_8,
)

self.team = Team.objects.create(episode=self.episode, name="t1")
self.user = User.objects.create_user(username="u1", email="u1@example.com")
self.team.members.add(self.user)

# 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
def test_patch_criterion_private(self):
criterion1 = EligibilityCriterion.objects.create(
title="crit1", description="desc1", icon="i1", is_private=False
)
criterion2 = EligibilityCriterion.objects.create(
title="crit2", description="desc2", icon="i2", is_private=True
)

self.episode.eligibility_criteria.add(criterion1)
self.episode.eligibility_criteria.add(criterion2)
self.client.force_authenticate(self.user)
response = self.client.patch(
reverse("team-me", kwargs={"episode_id": "ep"}),
{"profile": {"eligible_for": [criterion1.pk]}},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.patch(
reverse("team-me", kwargs={"episode_id": "ep"}),
{"profile": {"eligible_for": [criterion1.pk, criterion2.pk]}},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_patch_criterion_wrong_episode(self):
criterion = EligibilityCriterion.objects.create(
title="crit", description="desc", icon="i", is_private=False
)
self.client.force_authenticate(self.user)
response = self.client.patch(
reverse("team-me", kwargs={"episode_id": "ep"}),
{"profile": {"eligible_for": [criterion.pk]}},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
24 changes: 24 additions & 0 deletions backend/siarnaq/api/teams/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import filters, mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import APIException
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response

from siarnaq.api.episodes.models import EligibilityCriterion
from siarnaq.api.episodes.permissions import IsEpisodeAvailable
from siarnaq.api.teams.exceptions import TeamMaxSizeExceeded
from siarnaq.api.teams.filters import (
Expand All @@ -38,6 +40,12 @@
logger = structlog.get_logger(__name__)


class ForbiddenEligiblity(APIException):
status_code = status.HTTP_403_FORBIDDEN
default_detail = "You are not permitted to select these eligibility criteria."
default_code = "forbidden_eligibility"


@extend_schema(
parameters=[
OpenApiParameter(
Expand Down Expand Up @@ -126,6 +134,22 @@ def me(self, request, *, episode_id):
serializer.save()
return Response(serializer.data)
case "patch":
if request.data["profile"] is not None:
# If user is editing their profile, verify that they've
# selected valid eligibility criteria.
if eligible_for := request.data["profile"]["eligible_for"]:
selected_criteria = EligibilityCriterion.objects.filter(
pk__in=eligible_for
)
episode_criteria = team.episode.eligibility_criteria.filter(
pk__in=eligible_for
)
if any(
criterion.is_private or criterion not in episode_criteria
for criterion in selected_criteria
):
raise ForbiddenEligiblity

serializer = self.get_serializer(team, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
Expand Down
Loading