From ac421acdb5ee9b94d142b0602664e9f420e73244 Mon Sep 17 00:00:00 2001 From: Luca Versari Date: Thu, 19 Jun 2025 01:49:46 +0200 Subject: [PATCH] Add a "opaque_id" column to submissions. This gives a stable ID that can be used to refer to a specific submission. --- cms/db/__init__.py | 2 +- cms/db/submission.py | 29 +++++++++++ cms/server/contest/handlers/contest.py | 5 +- cms/server/contest/handlers/tasksubmission.py | 18 +++---- cms/server/contest/submission/workflow.py | 4 +- .../contest/templates/macro/submission.html | 15 +++--- cmscontrib/AddSubmission.py | 13 +++-- cmscontrib/updaters/update_45.py | 48 +++++++++++++++++++ cmscontrib/updaters/update_from_1.5.sql | 6 +++ .../unit_tests/cmscontrib/DumpImporterTest.py | 3 +- cmstestsuite/unit_tests/databasemixin.py | 1 + 11 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 cmscontrib/updaters/update_45.py diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 3344ac10c7..1e7ee98152 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -81,7 +81,7 @@ # Instantiate or import these objects. -version = 44 +version = 45 engine = create_engine(config.database, echo=config.database_debug, pool_timeout=60, pool_recycle=120) diff --git a/cms/db/submission.py b/cms/db/submission.py index d262789828..6dacba9b7f 100644 --- a/cms/db/submission.py +++ b/cms/db/submission.py @@ -27,6 +27,7 @@ """ from datetime import datetime +import random from sqlalchemy import Boolean from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import relationship @@ -46,6 +47,15 @@ class Submission(Base): """ __tablename__ = 'submissions' + __table_args__ = ( + UniqueConstraint("participation_id", "opaque_id", + name="participation_opaque_unique"), + ) + + # Opaque ID to be used to refer to this submission. + opaque_id: int = Column( + BigInteger, + nullable=False) # Auto increment primary key. id: int = Column( @@ -177,6 +187,25 @@ def tokened(self) -> bool: """ return self.token is not None + @classmethod + def generate_opaque_id(cls, session, participation_id): + randint_upper_bound = 2**63-1 + + opaque_id = random.randint(0, randint_upper_bound) + + # Note that in theory this may cause the transaction to fail by + # generating a non-actually-unique ID. This is however extremely + # unlikely (prob. ~num_parallel_submissions_per_contestant^2/2**63). + while (session + .query(Submission) + .filter(Submission.participation_id == participation_id) + .filter(Submission.opaque_id == opaque_id) + .first() + is not None): + opaque_id = random.randint(0, randint_upper_bound) + + return opaque_id + class File(Base): """Class to store information about one file submitted within a diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 77e7f690b9..a79a53d0d7 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -250,7 +250,7 @@ def get_task(self, task_name: str) -> Task | None: .filter(Task.name == task_name) \ .one_or_none() - def get_submission(self, task: Task, submission_num: str) -> Submission | None: + def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None: """Return the num-th contestant's submission on the given task. task: a task for the contest that is being served. @@ -265,8 +265,7 @@ def get_submission(self, task: Task, submission_num: str) -> Submission | None: return self.sql_session.query(Submission) \ .filter(Submission.participation == self.current_user) \ .filter(Submission.task == task) \ - .order_by(Submission.timestamp) \ - .offset(int(submission_num) - 1) \ + .filter(Submission.opaque_id == int(opaque_id)) \ .first() def get_user_test(self, task: Task, user_test_num: int) -> UserTest | None: diff --git a/cms/server/contest/handlers/tasksubmission.py b/cms/server/contest/handlers/tasksubmission.py index a05f68a116..c91e7eb75f 100644 --- a/cms/server/contest/handlers/tasksubmission.py +++ b/cms/server/contest/handlers/tasksubmission.py @@ -242,12 +242,12 @@ def add_task_score(self, participation: Participation, task: Task, data: dict): @tornado_web.authenticated @actual_phase_required(0, 1, 2, 3, 4) @multi_contest - def get(self, task_name, submission_num): + def get(self, task_name, opaque_id): task = self.get_task(task_name) if task is None: raise tornado_web.HTTPError(404) - submission = self.get_submission(task, submission_num) + submission = self.get_submission(task, opaque_id) if submission is None: raise tornado_web.HTTPError(404) @@ -284,7 +284,7 @@ def get(self, task_name, submission_num): round(score_type.max_score, task.score_precision) if data["status"] == SubmissionResult.SCORED \ and (submission.token is not None - or self.r_params["actual_phase"] == 3): + or self.r_params["actual_phase"] == 3): data["score"] = \ round(sr.score, task.score_precision) data["score_message"] = score_type.format_score( @@ -302,12 +302,12 @@ class SubmissionDetailsHandler(ContestHandler): @tornado_web.authenticated @actual_phase_required(0, 1, 2, 3, 4) @multi_contest - def get(self, task_name, submission_num): + def get(self, task_name, opaque_id): task = self.get_task(task_name) if task is None: raise tornado_web.HTTPError(404) - submission = self.get_submission(task, submission_num) + submission = self.get_submission(task, opaque_id) if submission is None: raise tornado_web.HTTPError(404) @@ -343,7 +343,7 @@ class SubmissionFileHandler(FileHandler): @tornado_web.authenticated @actual_phase_required(0, 1, 2, 3, 4) @multi_contest - def get(self, task_name, submission_num, filename): + def get(self, task_name, opaque_id, filename): if not self.contest.submissions_download_allowed: raise tornado_web.HTTPError(404) @@ -351,7 +351,7 @@ def get(self, task_name, submission_num, filename): if task is None: raise tornado_web.HTTPError(404) - submission = self.get_submission(task, submission_num) + submission = self.get_submission(task, opaque_id) if submission is None: raise tornado_web.HTTPError(404) @@ -389,12 +389,12 @@ class UseTokenHandler(ContestHandler): @tornado_web.authenticated @actual_phase_required(0) @multi_contest - def post(self, task_name, submission_num): + def post(self, task_name, opaque_id): task = self.get_task(task_name) if task is None: raise tornado_web.HTTPError(404) - submission = self.get_submission(task, submission_num) + submission = self.get_submission(task, opaque_id) if submission is None: raise tornado_web.HTTPError(404) diff --git a/cms/server/contest/submission/workflow.py b/cms/server/contest/submission/workflow.py index 32922f5eed..4e14458d2c 100644 --- a/cms/server/contest/submission/workflow.py +++ b/cms/server/contest/submission/workflow.py @@ -221,10 +221,12 @@ def accept_submission( participation.user.username) # Use the filenames of the contestant as a default submission comment - received_filenames_joined = ",".join([file.filename for file in received_files]) + received_filenames_joined = ",".join( + [file.filename for file in received_files]) submission = Submission( timestamp=timestamp, + opaque_id=Submission.generate_opaque_id(sql_session, participation.id), language=language.name if language is not None else None, task=task, participation=participation, diff --git a/cms/server/contest/templates/macro/submission.html b/cms/server/contest/templates/macro/submission.html index 22706cbf9e..e7832ebc4e 100644 --- a/cms/server/contest/templates/macro/submission.html +++ b/cms/server/contest/templates/macro/submission.html @@ -95,7 +95,7 @@ xsrf_form_html, actual_phase, s, - submissions|length - loop.index0, + s.opaque_id, show_date, can_use_tokens, can_play_token, @@ -109,7 +109,7 @@ {%- endmacro %} {% macro row(url, contest_url, translation, xsrf_form_html, - actual_phase, s, s_num, show_date, + actual_phase, s, opaque_id, show_date, can_use_tokens, can_play_token, can_play_token_now, submissions_download_allowed) -%} {# @@ -121,8 +121,7 @@ xsrf_form_html (str): input element for the XSRF protection. actual_phase (int): phase of the contest. s (Submission): the submission to display. -s_num (int): 1-based position of the submission in the list of - submissions of the currently logged in participaiton on this task. +opaque_id (int): opaque id of the submission. show_date (bool): whether to display only the time or also the date. can_use_tokens (bool): whether tokens are allowed for this task. can_play_token (bool): if can_use_tokens is true, whether the user has a @@ -138,7 +137,7 @@ {% set score_type = get_score_type(dataset=task.active_dataset) %} {% set sr = s.get_result(task.active_dataset) or undefined %} {% set status = sr.get_status() if sr is defined else SubmissionResult.COMPILING %} - + {% if show_date %} {{ s.timestamp|format_datetime }} {% else %} @@ -198,7 +197,7 @@ {% if s.language is not none %} {% set filename = filename|replace(".%l", (s.language|to_language).source_extension) %} {% endif %} - + {% trans %}Download{% endtrans %} {% else %} @@ -216,7 +215,7 @@ {% endif %} {% endif %}
  • - + {{ filename }}
  • @@ -233,7 +232,7 @@ {% else %} {% if can_play_token_now %} {# Can play a token right now: show the button. #} -
    + {{ xsrf_form_html|safe }}
    diff --git a/cmscontrib/AddSubmission.py b/cmscontrib/AddSubmission.py index b58e417fa7..7410a2be94 100755 --- a/cmscontrib/AddSubmission.py +++ b/cmscontrib/AddSubmission.py @@ -145,8 +145,14 @@ def add_submission( return False # Create objects in the DB. - submission = Submission(make_datetime(timestamp), language_name, - participation=participation, task=task) + + submission = Submission( + timestamp=make_datetime(timestamp), + language=language_name, + participation=participation, + task=task, + opaque_id=Submission.generate_opaque_id(session, participation.id) + ) for filename, digest in file_digests.items(): session.add(File(filename, digest, submission=submission)) session.add(submission) @@ -188,7 +194,8 @@ def main() -> int: import time args.timestamp = time.time() - split_files: list[tuple[str, str]] = [file_.split(":", 1) for file_ in args.file] + split_files: list[tuple[str, str]] = [ + file_.split(":", 1) for file_ in args.file] if any(len(file_) != 2 for file_ in split_files): parser.error("Invalid value for the file argument: format is " ":.") diff --git a/cmscontrib/updaters/update_45.py b/cmscontrib/updaters/update_45.py new file mode 100644 index 0000000000..f77c175563 --- /dev/null +++ b/cmscontrib/updaters/update_45.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +# Contest Management System - http://cms-dev.github.io/ +# Copyright © 2025 Luca Versari +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""A class to update a dump created by CMS. + +Used by DumpImporter and DumpUpdater. + +This adapts the dump to some changes in the model introduced in the +commit that created this same file. + +""" + + +import random + + +class Updater: + + def __init__(self, data): + assert data["_version"] == 44 + self.objs = data + + def run(self): + used_ids = set() + for k, v in self.objs.items(): + if k.startswith("_"): + continue + if v["_class"] == "Submission": + while "opaque_id" not in v or v["opaque_id"] in used_ids: + v["opaque_id"] = random.randint(0, 2**63-1) + used_ids.add(v["opaque_id"]) + + return self.objs diff --git a/cmscontrib/updaters/update_from_1.5.sql b/cmscontrib/updaters/update_from_1.5.sql index a66f0eaac4..d3ce3f3498 100644 --- a/cmscontrib/updaters/update_from_1.5.sql +++ b/cmscontrib/updaters/update_from_1.5.sql @@ -14,4 +14,10 @@ ALTER TABLE public.contests ALTER COLUMN allow_unofficial_submission_before_anal -- https://github.com/cms-dev/cms/pull/1393 ALTER TABLE public.submission_results ADD COLUMN scored_at timestamp without time zone; +-- https://github.com/cms-dev/cms/pull/1419 +ALTER TABLE submissions ADD COLUMN opaque_id BIGINT; +UPDATE submissions SET opaque_id = id WHERE opaque_id IS NULL; +ALTER TABLE submissions ADD CONSTRAINT participation_opaque_unique UNIQUE (participation_id, opaque_id); +ALTER TABLE submissions ALTER COLUMN opaque_id SET NOT NULL; + COMMIT; diff --git a/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py b/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py index c0451cb00b..d35a037e0d 100755 --- a/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py +++ b/cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py @@ -92,6 +92,7 @@ class TestDumpImporter(DatabaseMixin, FileSystemMixin, unittest.TestCase): }, "sub_key": { "_class": "Submission", + "opaque_id": 458958398291, "timestamp": 1_234_567_890.123, "participation": "part_key", "task": "task_key", @@ -187,7 +188,7 @@ def assertContestInDb(self, name, description, task_names_and_titles, self.assertCountEqual([(t.name, t.title) for t in c.tasks], task_names_and_titles) self.assertCountEqual([(u.user.username, u.user.last_name) - for u in c.participations], + for u in c.participations], usernames_and_last_names) def assertContestNotInDb(self, name): diff --git a/cmstestsuite/unit_tests/databasemixin.py b/cmstestsuite/unit_tests/databasemixin.py index 94ef43daa5..f24c7b6e14 100644 --- a/cmstestsuite/unit_tests/databasemixin.py +++ b/cmstestsuite/unit_tests/databasemixin.py @@ -198,6 +198,7 @@ def get_submission(cls, task=None, participation=None, **kwargs): args = { "task": task, "participation": participation, + "opaque_id": unique_long_id(), "timestamp": (task.contest.start + timedelta(0, unique_long_id())), } args.update(kwargs)