diff --git a/cms/db/contest.py b/cms/db/contest.py index 91dbc48b91..ff8454c5d5 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -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'"), diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index 850bc8bc72..9861214c5e 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -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") diff --git a/cms/server/admin/templates/contest.html b/cms/server/admin/templates/contest.html index 0e3c0cd1c1..a6005a0c7c 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -338,6 +338,13 @@

Contest configuration

+ + + + Minimum submission interval grace period + + + = 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 diff --git a/cms/server/contest/submission/workflow.py b/cms/server/contest/submission/workflow.py index c451045011..b9e556b0fc 100644 --- a/cms/server/contest/submission/workflow.py +++ b/cms/server/contest/submission/workflow.py @@ -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, \ @@ -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. diff --git a/cmstestsuite/unit_tests/server/contest/submission/check_test.py b/cmstestsuite/unit_tests/server/contest/submission/check_test.py index d1c8fa65dc..54cc6c5f4b 100755 --- a/cmstestsuite/unit_tests/server/contest/submission/check_test.py +++ b/cmstestsuite/unit_tests/server/contest/submission/check_test.py @@ -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 @@ -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() diff --git a/cmstestsuite/unit_tests/server/contest/submission/workflow_test.py b/cmstestsuite/unit_tests/server/contest/submission/workflow_test.py index ffb93ac017..6cc57d9137 100755 --- a/cmstestsuite/unit_tests/server/contest/submission/workflow_test.py +++ b/cmstestsuite/unit_tests/server/contest/submission/workflow_test.py @@ -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() @@ -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 @@ -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