Skip to content

Commit

Permalink
Add config to disable submission rate limit near the end of contest
Browse files Browse the repository at this point in the history
This adds a new config to a contest, "Minimum submission interval grace period"
which is the amount of time until the end of participation in which the minimum
interval between submissions is not enforced.

Cherry-picked from ioi/cms@cb141cb
  • Loading branch information
aguss787 authored and vytisb committed Feb 21, 2024
1 parent 415bce8 commit 8c0abbc
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 18 deletions.
4 changes: 4 additions & 0 deletions cms/db/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ class Contest(Base):
Interval,
CheckConstraint("min_submission_interval > '0 seconds'"),
nullable=True)
min_submission_interval_grace_period = Column(
Interval,
CheckConstraint("min_submission_interval_grace_period > '0 seconds'"),
nullable=True)
min_user_test_interval = Column(
Interval,
CheckConstraint("min_user_test_interval > '0 seconds'"),
Expand Down
1 change: 1 addition & 0 deletions cms/server/admin/handlers/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def post(self, contest_id):
self.get_int(attrs, "max_submission_number")
self.get_int(attrs, "max_user_test_number")
self.get_timedelta_sec(attrs, "min_submission_interval")
self.get_timedelta_sec(attrs, "min_submission_interval_grace_period")
self.get_timedelta_sec(attrs, "min_user_test_interval")

self.get_datetime(attrs, "start")
Expand Down
7 changes: 7 additions & 0 deletions cms/server/admin/templates/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,13 @@ <h1>Contest configuration</h1>
</td>
<td><input type="text" name="min_submission_interval" value="{{ contest.min_submission_interval.total_seconds()|int if contest.min_submission_interval is not none else "" }}"></td>
</tr>
<tr>
<td>
<span class="info" title="The amount of time (in seconds) until the end of participation in which the minimum interval between submissions is not enforced."></span>
Minimum submission interval grace period
</td>
<td><input type="text" name="min_submission_interval_grace_period" value="{{ contest.min_submission_interval_grace_period.total_seconds()|int if contest.min_submission_interval_grace_period is not none else "" }}"></td>
</tr>
<tr>
<td>
<span class="info" title="The minimum amount of time (in seconds) that a contestant needs to wait after a user test before being able to send another.
Expand Down
4 changes: 2 additions & 2 deletions cms/server/contest/submission/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"""

from .check import get_submission_count, check_max_number, \
get_latest_submission, check_min_interval
get_latest_submission, check_min_interval, is_last_minutes
from .file_matching import InvalidFilesOrLanguage, match_files_and_language
from .file_retrieval import ReceivedFile, InvalidArchive, \
extract_files_from_archive, extract_files_from_tornado
Expand All @@ -40,7 +40,7 @@
__all__ = [
# check.py
"get_submission_count", "check_max_number", "get_latest_submission",
"check_min_interval",
"check_min_interval", "is_last_minutes",
# file_retrieval.py
"ReceivedFile", "InvalidArchive", "extract_files_from_archive",
"extract_files_from_tornado",
Expand Down
26 changes: 26 additions & 0 deletions cms/server/contest/submission/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
exported as they may be of general interest.
"""
from datetime import datetime

from sqlalchemy import desc, func

from cms.db import Task, Submission
from cms.db.user import Participation


def _filter_submission_query(q, participation, contest, task, cls):
Expand Down Expand Up @@ -174,3 +176,27 @@ def check_min_interval(
sql_session, participation, contest=contest, task=task, cls=cls)
return (submission is None
or timestamp - submission.timestamp >= min_interval)


def is_last_minutes(timestamp: datetime, participation: Participation):
"""
timestamp (datetime): the current timestamp.
participation (Participation): the participation to be checked.
delta (timedelta): length of the last time section to be checked.
return (bool): whether it is the last `delta` of the participation.
"""

if participation.unrestricted \
or participation.contest.min_submission_interval_grace_period is None:
return False

if participation.contest.per_user_time is None:
end_time = participation.contest.stop
else:
end_time = participation.starting_time + participation.contest.per_user_time

end_time += participation.delay_time + participation.extra_time
time_left = end_time - timestamp
return time_left <= \
participation.contest.min_submission_interval_grace_period
31 changes: 16 additions & 15 deletions cms/server/contest/submission/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from cms import config
from cms.db import Submission, File, UserTestManager, UserTestFile, UserTest
from cmscommon.datetime import make_timestamp
from .check import check_max_number, check_min_interval
from .check import check_max_number, check_min_interval, is_last_minutes
from .file_matching import InvalidFilesOrLanguage, match_files_and_language
from .file_retrieval import InvalidArchive, extract_files_from_tornado
from .utils import fetch_file_digests_from_previous_submission, StorageFailed, \
Expand Down Expand Up @@ -110,21 +110,22 @@ def accept_submission(sql_session, file_cacher, participation, task, timestamp,
"at most %d submissions on this task."),
task.max_submission_number)

if not check_min_interval(sql_session, contest.min_submission_interval,
timestamp, participation, contest=contest):
raise UnacceptableSubmission(
N_("Submissions too frequent!"),
N_("Among all tasks, you can submit again "
"after %d seconds from last submission."),
contest.min_submission_interval.total_seconds())
if not is_last_minutes(timestamp, participation):
if not check_min_interval(sql_session, contest.min_submission_interval,
timestamp, participation, contest=contest):
raise UnacceptableSubmission(
N_("Submissions too frequent!"),
N_("Among all tasks, you can submit again "
"after %d seconds from last submission."),
contest.min_submission_interval.total_seconds())

if not check_min_interval(sql_session, task.min_submission_interval,
timestamp, participation, task=task):
raise UnacceptableSubmission(
N_("Submissions too frequent!"),
N_("For this task, you can submit again "
"after %d seconds from last submission."),
task.min_submission_interval.total_seconds())
if not check_min_interval(sql_session, task.min_submission_interval,
timestamp, participation, task=task):
raise UnacceptableSubmission(
N_("Submissions too frequent!"),
N_("For this task, you can submit again "
"after %d seconds from last submission."),
task.min_submission_interval.total_seconds())

# Process the data we received and ensure it's valid.

Expand Down
83 changes: 82 additions & 1 deletion cmstestsuite/unit_tests/server/contest/submission/check_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from cms.db import UserTest, Submission
from cms.server.contest.submission import get_submission_count, \
check_max_number, get_latest_submission, check_min_interval
check_max_number, get_latest_submission, check_min_interval, is_last_minutes
from cmscommon.datetime import make_datetime


Expand Down Expand Up @@ -399,5 +399,86 @@ def test_limit_unrestricted(self):
self.get_latest_submission.assert_not_called()


class TestIsLastMinutes(DatabaseMixin, unittest.TestCase):

def setUp(self):
super().setUp()
self.contest = self.add_contest()
self.task = self.add_task(contest=self.contest)
self.participation = self.add_participation(unrestricted=False,
contest=self.contest)

self.timestamp = make_datetime()

def test_unconfigured_min_submission_interval_grace_period(self):
self.setup_contest_with_no_user_time()

self.assertFalse(
is_last_minutes(self.timestamp, self.participation))

def test_no_per_user_time_and_last_minutes(self):
self.setup_contest_with_no_user_time()
self.contest.min_submission_interval_grace_period = timedelta(minutes=15)

self.assertTrue(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_no_per_user_time_and_not_last_minutes(self):
self.setup_contest_with_no_user_time()
self.contest.min_submission_interval_grace_period = timedelta(minutes=10)

self.assertFalse(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_per_user_time_and_last_minutes(self):
self.participation.contest.per_user_time = timedelta(hours=5)
self.participation.contest.start = self.timestamp - timedelta(hours=10)
self.participation.contest.stop = self.timestamp
self.participation.starting_time = self.timestamp - timedelta(hours=5)
self.contest.min_submission_interval_grace_period = timedelta(minutes=15)

self.assertTrue(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_per_user_time_and_not_last_minutes(self):
self.participation.contest.per_user_time = timedelta(hours=5)
self.participation.contest.start = self.timestamp - timedelta(hours=10)
self.participation.contest.stop = self.timestamp
self.participation.starting_time = self.timestamp - timedelta(hours=5)
self.contest.min_submission_interval_grace_period = timedelta(minutes=10)

self.assertFalse(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_consider_extra_time(self):
self.setup_contest_with_no_user_time()

self.participation.extra_time = timedelta(seconds=1)
self.contest.min_submission_interval_grace_period = timedelta(minutes=15)

self.assertFalse(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_consider_delay(self):
self.setup_contest_with_no_user_time()

self.participation.delay_time = timedelta(seconds=1)
self.contest.min_submission_interval_grace_period = timedelta(minutes=15)

self.assertFalse(
is_last_minutes(self.timestamp - timedelta(minutes=15), self.participation))

def test_unrestricted_participation(self):
self.setup_contest_with_no_user_time()
self.participation.unrestricted = True

self.assertFalse(is_last_minutes(self.timestamp, self.participation))

def setup_contest_with_no_user_time(self):
self.participation.contest.per_user_time = None
self.participation.contest.start = self.timestamp - timedelta(hours=5)
self.participation.contest.stop = self.timestamp


if __name__ == "__main__":
unittest.main()
32 changes: 32 additions & 0 deletions cmstestsuite/unit_tests/server/contest/submission/workflow_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ def setUp(self):
self.addCleanup(patcher.stop)
self.check_min_interval.return_value = True

patcher = patch(
"cms.server.contest.submission.workflow.is_last_minutes")
self.is_last_minutes = patcher.start()
self.addCleanup(patcher.stop)
self.is_last_minutes.return_value = False

patcher = patch(
"cms.server.contest.submission.workflow.extract_files_from_tornado")
self.extract_files_from_tornado = patcher.start()
Expand Down Expand Up @@ -251,6 +257,19 @@ def test_failure_due_to_min_interval_on_contest(self):
self.session, min_interval, self.timestamp, self.participation,
contest=self.contest)

def test_success_with_min_interval_on_contest_in_last_minutes(self):
min_interval = timedelta(seconds=unique_long_id())
self.contest.min_submission_interval = min_interval
# False only when we ask for contest.
self.check_min_interval.side_effect = \
lambda *args, **kwargs: "contest" not in kwargs
self.is_last_minutes.return_value = True

self.call()

self.is_last_minutes.assert_called_with(
self.timestamp, self.participation)

def test_failure_due_to_min_interval_on_task(self):
min_interval = timedelta(seconds=unique_long_id())
self.task.min_submission_interval = min_interval
Expand All @@ -266,6 +285,19 @@ def test_failure_due_to_min_interval_on_task(self):
self.session, min_interval, self.timestamp, self.participation,
task=self.task)

def test_success_with_min_interval_on_task_in_last_minutes(self):
min_interval = timedelta(seconds=unique_long_id())
self.task.min_submission_interval = min_interval
# False only when we ask for task.
self.check_min_interval.side_effect = \
lambda *args, **kwargs: "task" not in kwargs
self.is_last_minutes.return_value = True

self.call()

self.is_last_minutes.assert_called_with(
self.timestamp, self.participation)

def test_failure_due_to_extract_files_from_tornado(self):
self.extract_files_from_tornado.side_effect = InvalidArchive

Expand Down

0 comments on commit 8c0abbc

Please sign in to comment.