Skip to content

Commit

Permalink
Scrimmage Rate Limiting (#918)
Browse files Browse the repository at this point in the history
here we go hope this doesn't break stuff :shipit:
  • Loading branch information
lowtorola authored Jan 23, 2025
1 parent 8e99f80 commit 4eada47
Show file tree
Hide file tree
Showing 29 changed files with 521 additions and 212 deletions.
32 changes: 28 additions & 4 deletions backend/siarnaq/api/compete/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,41 @@ def update_match_ratings(instance, **kwargs):


@receiver(post_save, sender=ScrimmageRequest)
def auto_accept_scrimmage(instance, created, **kwargs):
"""Automatically accept a scrimmage request if the team prefers to do so."""
def auto_accept_reject_scrimmage(instance, created, **kwargs):
"""
Automatically accept or reject a scrimmage request if the team prefers to do so.
"""
from siarnaq.api.teams.models import ScrimmageRequestAcceptReject

if not created:
return
if (instance.is_ranked and instance.requested_to.profile.auto_accept_ranked) or (
not instance.is_ranked and instance.requested_to.profile.auto_accept_unranked

if (
instance.is_ranked
and instance.requested_to.profile.auto_accept_reject_ranked
== ScrimmageRequestAcceptReject.AUTO_ACCEPT
) or (
not instance.is_ranked
and instance.requested_to.profile.auto_accept_reject_unranked
== ScrimmageRequestAcceptReject.AUTO_ACCEPT
):
# Must wait for transaction to complete, so that maps are inserted.
transaction.on_commit(
lambda: ScrimmageRequest.objects.filter(pk=instance.pk).accept()
)
elif (
instance.is_ranked
and instance.requested_to.profile.auto_accept_reject_ranked
== ScrimmageRequestAcceptReject.AUTO_REJECT
) or (
not instance.is_ranked
and instance.requested_to.profile.auto_accept_reject_unranked
== ScrimmageRequestAcceptReject.AUTO_REJECT
):
# Must wait for transaction to complete, so that we safely reject
transaction.on_commit(
lambda: ScrimmageRequest.objects.filter(pk=instance.pk).reject()
)


@receiver(pre_save, sender=Match)
Expand Down
15 changes: 11 additions & 4 deletions backend/siarnaq/api/compete/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@
TournamentRound,
TournamentStyle,
)
from siarnaq.api.teams.models import Rating, Team, TeamStatus
from siarnaq.api.teams.models import (
Rating,
ScrimmageRequestAcceptReject,
Team,
TeamStatus,
)
from siarnaq.api.user.models import User


Expand Down Expand Up @@ -1205,8 +1210,8 @@ def setUp(self):
episode=self.e1 if i < 2 else self.e2,
name=f"team{i}",
profile=dict(
auto_accept_ranked=False,
auto_accept_unranked=False,
auto_accept_reject_ranked=ScrimmageRequestAcceptReject.MANUAL,
auto_accept_reject_unranked=ScrimmageRequestAcceptReject.MANUAL,
),
)
t.members.add(u)
Expand All @@ -1233,7 +1238,9 @@ def setUp(self):
)
def test_create_autoaccept(self, enqueue):
self.client.force_authenticate(self.users[0])
self.teams[1].profile.auto_accept_ranked = True
self.teams[
1
].profile.auto_accept_reject_ranked = ScrimmageRequestAcceptReject.AUTO_ACCEPT
self.teams[1].profile.save()
response = self.client.post(
reverse("request-list", kwargs={"episode_id": "e1"}),
Expand Down
11 changes: 11 additions & 0 deletions backend/siarnaq/api/compete/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ class RankedMatchesDisabed(APIException):
default_code = "ranked_matches_disabled"


class ScrimmageRateLimited(APIException):
status_code = status.HTTP_429_TOO_MANY_REQUESTS
default_detail = "You have requested too many scrimmages in the past hour."
default_code = "scrimmages_rate_limited"


class EpisodeTeamUserContextMixin:
"""Add the current episode, team and user to the serializer context."""

Expand Down Expand Up @@ -966,6 +972,11 @@ def create(self, request, *, episode_id):
):
raise TooManyScrimmages

# Check if we should reject based on rate-limiting
requestor = Team.objects.get(pk=serializer.validated_data["requested_by_id"])
if not requestor.can_scrimmage(serializer.validated_data["is_ranked"]):
raise ScrimmageRateLimited

serializer.save()
# Generate the Location header, as supplied by CreateModelMixin
headers = self.get_success_headers(serializer.data)
Expand Down
2 changes: 2 additions & 0 deletions backend/siarnaq/api/episodes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ class EpisodeAdmin(admin.ModelAdmin):
"submission_frozen",
"is_allowed_ranked_scrimmage",
"autoscrim_schedule",
"ranked_scrimmage_hourly_limit",
"unranked_scrimmage_hourly_limit",
),
},
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.1.2 on 2025-01-23 08:04

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("episodes", "0010_episode_is_allowed_ranked_scrimmage"),
]

operations = [
migrations.AddField(
model_name="episode",
name="ranked_scrimmage_hourly_limit",
field=models.PositiveSmallIntegerField(default=10),
),
migrations.AddField(
model_name="episode",
name="unranked_scrimmage_hourly_limit",
field=models.PositiveSmallIntegerField(default=10),
),
]
8 changes: 8 additions & 0 deletions backend/siarnaq/api/episodes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ class Episode(models.Model):
is_allowed_ranked_scrimmage = models.BooleanField(default=True)
"""Whether ranked scrimmages are allowed in this episode."""

ranked_scrimmage_hourly_limit = models.PositiveSmallIntegerField(default=10)
"""The maximum number of ranked scrimmage requests a team can create in an hour."""

unranked_scrimmage_hourly_limit = models.PositiveSmallIntegerField(default=10)
"""
The maximum number of unranked scrimmage requests a team can create in an hour.
"""

objects = EpisodeQuerySet.as_manager()

def __str__(self):
Expand Down
4 changes: 2 additions & 2 deletions backend/siarnaq/api/teams/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ class TeamProfileInline(admin.StackedInline):
fields = (
"quote",
"biography",
"auto_accept_ranked",
"auto_accept_unranked",
"auto_accept_reject_ranked",
"auto_accept_reject_unranked",
"rating",
"has_avatar",
"eligible_for",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 4.1.2 on 2025-01-23 07:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("teams", "0007_teamprofile_has_report"),
]

operations = [
migrations.RemoveField(
model_name="teamprofile",
name="auto_accept_ranked",
),
migrations.RemoveField(
model_name="teamprofile",
name="auto_accept_unranked",
),
migrations.AddField(
model_name="teamprofile",
name="auto_accept_reject_ranked",
field=models.CharField(
choices=[("A", "Auto Accept"), ("R", "Auto Reject"), ("M", "Manual")],
default="M",
max_length=1,
),
),
migrations.AddField(
model_name="teamprofile",
name="auto_accept_reject_unranked",
field=models.CharField(
choices=[("A", "Auto Accept"), ("R", "Auto Reject"), ("M", "Manual")],
default="M",
max_length=1,
),
),
]
63 changes: 59 additions & 4 deletions backend/siarnaq/api/teams/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models, transaction
from django.utils import timezone

import siarnaq.api.refs as refs
from siarnaq.api.teams.managers import TeamQuerySet
Expand Down Expand Up @@ -85,6 +86,22 @@ class TeamStatus(models.TextChoices):
"""


class ScrimmageRequestAcceptReject(models.TextChoices):
"""
An immutable type enumerating the possible varieties of scrimmage
request auto accept/reject settings.
"""

AUTO_ACCEPT = "A"
"""Automatically accept all scrimmage requests."""

AUTO_REJECT = "R"
"""Automatically reject all scrimmage requests."""

MANUAL = "M"
"""Manually accept or reject scrimmage requests."""


def make_team_join_key():
return uuid.uuid4().hex[:16]

Expand Down Expand Up @@ -168,6 +185,36 @@ def get_active_submission(self):
def get_non_staff_count(self):
return self.members.filter(is_staff=False).count()

def can_scrimmage(self, is_ranked):
"""
Check whether this team is currently rate-limited from
creating a given type of scrimmage request.
is_ranked determines which type of scrimmage is being checked.
"""
from siarnaq.api.compete.models import MatchParticipant
from siarnaq.api.episodes.models import Episode

past_hour = timezone.now() - timezone.timedelta(hours=1)

# Get this team's scrimmages created in the past hour
match_count = MatchParticipant.objects.filter(
team_id=self.id,
match__episode=self.episode,
match__tournament_round__isnull=True,
match__is_ranked=is_ranked,
match__created__gte=past_hour,
).count()

# Get the maximum number of created matches allowed in the past hour
episode = Episode.objects.get(pk=self.episode_id)
max_matches = (
episode.ranked_scrimmage_hourly_limit
if is_ranked
else episode.unranked_scrimmage_hourly_limit
)

return match_count < max_matches


class TeamProfile(models.Model):
"""
Expand Down Expand Up @@ -204,11 +251,19 @@ class TeamProfile(models.Model):
rating = models.OneToOneField(Rating, on_delete=models.PROTECT)
"""The current rating of the team."""

auto_accept_ranked = models.BooleanField(default=False)
"""Whether the team automatically accepts ranked match requests."""
auto_accept_reject_ranked = models.CharField(
max_length=1,
choices=ScrimmageRequestAcceptReject.choices,
default=ScrimmageRequestAcceptReject.MANUAL,
)
"""Whether the team automatically accepts or rejects ranked match requests."""

auto_accept_unranked = models.BooleanField(default=True)
"""Whether the team automatically accepts unranked match requests."""
auto_accept_reject_unranked = models.CharField(
max_length=1,
choices=ScrimmageRequestAcceptReject.choices,
default=ScrimmageRequestAcceptReject.MANUAL,
)
"""Whether the team automatically accepts or rejects unranked match requests."""

eligible_for = models.ManyToManyField(
refs.ELIGIBILITY_CRITERION_MODEL, related_name="teams", blank=True
Expand Down
8 changes: 4 additions & 4 deletions backend/siarnaq/api/teams/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class Meta:
"has_avatar",
"avatar_url",
"rating",
"auto_accept_ranked",
"auto_accept_unranked",
"auto_accept_reject_ranked",
"auto_accept_reject_unranked",
"eligible_for",
]
read_only_fields = ["rating", "has_avatar", "avatar_url"]
Expand Down Expand Up @@ -79,8 +79,8 @@ class Meta:
"has_report",
"avatar_url",
"rating",
"auto_accept_ranked",
"auto_accept_unranked",
"auto_accept_reject_ranked",
"auto_accept_reject_unranked",
"eligible_for",
]
read_only_fields = ["rating", "has_avatar", "avatar_url", "has_report"]
Expand Down
5 changes: 5 additions & 0 deletions backend/siarnaq/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,4 +524,9 @@ def dropper(logger, method_name, event_dict):
"AUTHENTICATION_WHITELIST": [
"rest_framework_simplejwt.authentication.JWTAuthentication"
],
"ENUM_NAME_OVERRIDES": {
"ScrimmageRequestAcceptRejectEnum": (
"siarnaq.api.teams.models.ScrimmageRequestAcceptReject"
),
},
}
1 change: 1 addition & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default [
],
"@typescript-eslint/non-nullable-type-assertion-style": "off",
"@typescript-eslint/no-non-null-assertion": "error",
"react/prop-types": "off",
},
},
{
Expand Down
30 changes: 18 additions & 12 deletions frontend/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3032,6 +3032,12 @@ components:
- requested_to_name
- requested_to_rating
- status
ScrimmageRequestAcceptRejectEnum:
enum:
- A
- R
- M
type: string
ScrimmageRequestRequest:
type: object
properties:
Expand Down Expand Up @@ -3281,10 +3287,10 @@ components:
type: number
format: double
readOnly: true
auto_accept_ranked:
type: boolean
auto_accept_unranked:
type: boolean
auto_accept_reject_ranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
auto_accept_reject_unranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
eligible_for:
type: array
items:
Expand All @@ -3304,10 +3310,10 @@ components:
biography:
type: string
maxLength: 1024
auto_accept_ranked:
type: boolean
auto_accept_unranked:
type: boolean
auto_accept_reject_ranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
auto_accept_reject_unranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
eligible_for:
type: array
items:
Expand All @@ -3332,10 +3338,10 @@ components:
type: number
format: double
readOnly: true
auto_accept_ranked:
type: boolean
auto_accept_unranked:
type: boolean
auto_accept_reject_ranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
auto_accept_reject_unranked:
$ref: "#/components/schemas/ScrimmageRequestAcceptRejectEnum"
eligible_for:
type: array
items:
Expand Down
Loading

0 comments on commit 4eada47

Please sign in to comment.