Skip to content

Commit 7d7eb3c

Browse files
committed
Add a "opaque_id" column to submissions.
This gives a stable ID that can be used to refer to a specific submission.
1 parent f934f26 commit 7d7eb3c

File tree

11 files changed

+118
-26
lines changed

11 files changed

+118
-26
lines changed

cms/db/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181

8282
# Instantiate or import these objects.
8383

84-
version = 44
84+
version = 45
8585

8686
engine = create_engine(config.database, echo=config.database_debug,
8787
pool_timeout=60, pool_recycle=120)

cms/db/submission.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"""
2828

2929
from datetime import datetime
30+
import random
3031
from sqlalchemy import Boolean
3132
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
3233
from sqlalchemy.orm import relationship
@@ -46,6 +47,15 @@ class Submission(Base):
4647
4748
"""
4849
__tablename__ = 'submissions'
50+
__table_args__ = (
51+
UniqueConstraint("participation_id", "opaque_id",
52+
name="participation_opaque_unique"),
53+
)
54+
55+
# Opaque ID to be used to refer to this submission.
56+
opaque_id: int = Column(
57+
BigInteger,
58+
nullable=False)
4959

5060
# Auto increment primary key.
5161
id: int = Column(
@@ -177,6 +187,25 @@ def tokened(self) -> bool:
177187
"""
178188
return self.token is not None
179189

190+
@classmethod
191+
def generate_opaque_id(cls, session, participation_id):
192+
randint_upper_bound = 2**63-1
193+
194+
opaque_id = random.randint(0, randint_upper_bound)
195+
196+
# Note that in theory this may cause the transaction to fail by
197+
# generating a non-actually-unique ID. This is however extremely
198+
# unlikely (prob. ~num_parallel_submissions_per_contestant^2/2**63).
199+
while (session
200+
.query(Submission)
201+
.filter(Submission.participation_id == participation_id)
202+
.filter(Submission.opaque_id == opaque_id)
203+
.first()
204+
is not None):
205+
opaque_id = random.randint(0, randint_upper_bound)
206+
207+
return opaque_id
208+
180209

181210
class File(Base):
182211
"""Class to store information about one file submitted within a

cms/server/contest/handlers/contest.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def get_task(self, task_name: str) -> Task | None:
250250
.filter(Task.name == task_name) \
251251
.one_or_none()
252252

253-
def get_submission(self, task: Task, submission_num: str) -> Submission | None:
253+
def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None:
254254
"""Return the num-th contestant's submission on the given task.
255255
256256
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:
265265
return self.sql_session.query(Submission) \
266266
.filter(Submission.participation == self.current_user) \
267267
.filter(Submission.task == task) \
268-
.order_by(Submission.timestamp) \
269-
.offset(int(submission_num) - 1) \
268+
.filter(Submission.opaque_id == int(opaque_id)) \
270269
.first()
271270

272271
def get_user_test(self, task: Task, user_test_num: int) -> UserTest | None:

cms/server/contest/handlers/tasksubmission.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,12 @@ def add_task_score(self, participation: Participation, task: Task, data: dict):
242242
@tornado_web.authenticated
243243
@actual_phase_required(0, 1, 2, 3, 4)
244244
@multi_contest
245-
def get(self, task_name, submission_num):
245+
def get(self, task_name, opaque_id):
246246
task = self.get_task(task_name)
247247
if task is None:
248248
raise tornado_web.HTTPError(404)
249249

250-
submission = self.get_submission(task, submission_num)
250+
submission = self.get_submission(task, opaque_id)
251251
if submission is None:
252252
raise tornado_web.HTTPError(404)
253253

@@ -284,7 +284,7 @@ def get(self, task_name, submission_num):
284284
round(score_type.max_score, task.score_precision)
285285
if data["status"] == SubmissionResult.SCORED \
286286
and (submission.token is not None
287-
or self.r_params["actual_phase"] == 3):
287+
or self.r_params["actual_phase"] == 3):
288288
data["score"] = \
289289
round(sr.score, task.score_precision)
290290
data["score_message"] = score_type.format_score(
@@ -302,12 +302,12 @@ class SubmissionDetailsHandler(ContestHandler):
302302
@tornado_web.authenticated
303303
@actual_phase_required(0, 1, 2, 3, 4)
304304
@multi_contest
305-
def get(self, task_name, submission_num):
305+
def get(self, task_name, opaque_id):
306306
task = self.get_task(task_name)
307307
if task is None:
308308
raise tornado_web.HTTPError(404)
309309

310-
submission = self.get_submission(task, submission_num)
310+
submission = self.get_submission(task, opaque_id)
311311
if submission is None:
312312
raise tornado_web.HTTPError(404)
313313

@@ -343,15 +343,15 @@ class SubmissionFileHandler(FileHandler):
343343
@tornado_web.authenticated
344344
@actual_phase_required(0, 1, 2, 3, 4)
345345
@multi_contest
346-
def get(self, task_name, submission_num, filename):
346+
def get(self, task_name, opaque_id, filename):
347347
if not self.contest.submissions_download_allowed:
348348
raise tornado_web.HTTPError(404)
349349

350350
task = self.get_task(task_name)
351351
if task is None:
352352
raise tornado_web.HTTPError(404)
353353

354-
submission = self.get_submission(task, submission_num)
354+
submission = self.get_submission(task, opaque_id)
355355
if submission is None:
356356
raise tornado_web.HTTPError(404)
357357

@@ -389,12 +389,12 @@ class UseTokenHandler(ContestHandler):
389389
@tornado_web.authenticated
390390
@actual_phase_required(0)
391391
@multi_contest
392-
def post(self, task_name, submission_num):
392+
def post(self, task_name, opaque_id):
393393
task = self.get_task(task_name)
394394
if task is None:
395395
raise tornado_web.HTTPError(404)
396396

397-
submission = self.get_submission(task, submission_num)
397+
submission = self.get_submission(task, opaque_id)
398398
if submission is None:
399399
raise tornado_web.HTTPError(404)
400400

cms/server/contest/submission/workflow.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,12 @@ def accept_submission(
221221
participation.user.username)
222222

223223
# Use the filenames of the contestant as a default submission comment
224-
received_filenames_joined = ",".join([file.filename for file in received_files])
224+
received_filenames_joined = ",".join(
225+
[file.filename for file in received_files])
225226

226227
submission = Submission(
227228
timestamp=timestamp,
229+
opaque_id=Submission.generate_opaque_id(sql_session, participation.id),
228230
language=language.name if language is not None else None,
229231
task=task,
230232
participation=participation,

cms/server/contest/templates/macro/submission.html

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
xsrf_form_html,
9696
actual_phase,
9797
s,
98-
submissions|length - loop.index0,
98+
s.opaque_id,
9999
show_date,
100100
can_use_tokens,
101101
can_play_token,
@@ -109,7 +109,7 @@
109109
{%- endmacro %}
110110

111111
{% macro row(url, contest_url, translation, xsrf_form_html,
112-
actual_phase, s, s_num, show_date,
112+
actual_phase, s, opaque_id, show_date,
113113
can_use_tokens, can_play_token, can_play_token_now,
114114
submissions_download_allowed) -%}
115115
{#
@@ -121,8 +121,7 @@
121121
xsrf_form_html (str): input element for the XSRF protection.
122122
actual_phase (int): phase of the contest.
123123
s (Submission): the submission to display.
124-
s_num (int): 1-based position of the submission in the list of
125-
submissions of the currently logged in participaiton on this task.
124+
opaque_id (int): opaque id of the submission.
126125
show_date (bool): whether to display only the time or also the date.
127126
can_use_tokens (bool): whether tokens are allowed for this task.
128127
can_play_token (bool): if can_use_tokens is true, whether the user has a
@@ -138,7 +137,7 @@
138137
{% set score_type = get_score_type(dataset=task.active_dataset) %}
139138
{% set sr = s.get_result(task.active_dataset) or undefined %}
140139
{% set status = sr.get_status() if sr is defined else SubmissionResult.COMPILING %}
141-
<tr data-submission="{{ s_num }}" data-status="{{ status }}">
140+
<tr data-submission="{{ opaque_id }}" data-status="{{ status }}">
142141
{% if show_date %}
143142
<td class="datetime">{{ s.timestamp|format_datetime }}</td>
144143
{% else %}
@@ -198,7 +197,7 @@
198197
{% if s.language is not none %}
199198
{% set filename = filename|replace(".%l", (s.language|to_language).source_extension) %}
200199
{% endif %}
201-
<a class="btn" href="{{ contest_url("tasks", task.name, "submissions", s_num, "files", filename) }}">
200+
<a class="btn" href="{{ contest_url("tasks", task.name, "submissions", opaque_id, "files", filename) }}">
202201
{% trans %}Download{% endtrans %}
203202
</a>
204203
{% else %}
@@ -216,7 +215,7 @@
216215
{% endif %}
217216
{% endif %}
218217
<li>
219-
<a href="{{ contest_url("tasks", task.name, "submissions", s_num, "files", filename) }}">
218+
<a href="{{ contest_url("tasks", task.name, "submissions", opaque_id, "files", filename) }}">
220219
{{ filename }}
221220
</a>
222221
</li>
@@ -233,7 +232,7 @@
233232
{% else %}
234233
{% if can_play_token_now %}
235234
{# Can play a token right now: show the button. #}
236-
<form action="{{ contest_url("tasks", task.name, "submissions", s_num, "token") }}" method="POST">
235+
<form action="{{ contest_url("tasks", task.name, "submissions", opaque_id, "token") }}" method="POST">
237236
{{ xsrf_form_html|safe }}
238237
<button type="submit" class="btn btn-warning">{% trans %}Play!{% endtrans %}</button>
239238
</form>

cmscontrib/AddSubmission.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,14 @@ def add_submission(
145145
return False
146146

147147
# Create objects in the DB.
148-
submission = Submission(make_datetime(timestamp), language_name,
149-
participation=participation, task=task)
148+
149+
submission = Submission(
150+
timestamp=make_datetime(timestamp),
151+
language=language_name,
152+
participation=participation,
153+
task=task,
154+
opaque_id=Submission.generate_opaque_id(session, participation.id)
155+
)
150156
for filename, digest in file_digests.items():
151157
session.add(File(filename, digest, submission=submission))
152158
session.add(submission)
@@ -188,7 +194,8 @@ def main() -> int:
188194
import time
189195
args.timestamp = time.time()
190196

191-
split_files: list[tuple[str, str]] = [file_.split(":", 1) for file_ in args.file]
197+
split_files: list[tuple[str, str]] = [
198+
file_.split(":", 1) for file_ in args.file]
192199
if any(len(file_) != 2 for file_ in split_files):
193200
parser.error("Invalid value for the file argument: format is "
194201
"<name>:<file>.")

cmscontrib/updaters/update_45.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
3+
# Contest Management System - http://cms-dev.github.io/
4+
# Copyright © 2025 Luca Versari <[email protected]>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+
"""A class to update a dump created by CMS.
20+
21+
Used by DumpImporter and DumpUpdater.
22+
23+
This adapts the dump to some changes in the model introduced in the
24+
commit that created this same file.
25+
26+
"""
27+
28+
29+
import random
30+
31+
32+
class Updater:
33+
34+
def __init__(self, data):
35+
assert data["_version"] == 44
36+
self.objs = data
37+
38+
def run(self):
39+
used_ids = set()
40+
for k, v in self.objs.items():
41+
if k.startswith("_"):
42+
continue
43+
if v["_class"] == "Submission":
44+
while "opaque_id" not in v or v["opaque_id"] in used_ids:
45+
v["opaque_id"] = random.randint(0, 2**63-1)
46+
used_ids.add(v["opaque_id"])
47+
48+
return self.objs

cmscontrib/updaters/update_from_1.5.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,10 @@ ALTER TABLE public.contests ALTER COLUMN allow_unofficial_submission_before_anal
1414
-- https://github.com/cms-dev/cms/pull/1393
1515
ALTER TABLE public.submission_results ADD COLUMN scored_at timestamp without time zone;
1616

17+
-- https://github.com/cms-dev/cms/pull/1419
18+
ALTER TABLE submissions ADD COLUMN opaque_id BIGINT;
19+
UPDATE submissions SET opaque_id = id WHERE opaque_id IS NULL;
20+
ALTER TABLE submissions ADD CONSTRAINT participation_opaque_unique UNIQUE (participation_id, opaque_id);
21+
ALTER TABLE submissions ALTER COLUMN opaque_id SET NOT NULL;
22+
1723
COMMIT;

cmstestsuite/unit_tests/cmscontrib/DumpImporterTest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class TestDumpImporter(DatabaseMixin, FileSystemMixin, unittest.TestCase):
9292
},
9393
"sub_key": {
9494
"_class": "Submission",
95+
"opaque_id": 458958398291,
9596
"timestamp": 1_234_567_890.123,
9697
"participation": "part_key",
9798
"task": "task_key",
@@ -187,7 +188,7 @@ def assertContestInDb(self, name, description, task_names_and_titles,
187188
self.assertCountEqual([(t.name, t.title) for t in c.tasks],
188189
task_names_and_titles)
189190
self.assertCountEqual([(u.user.username, u.user.last_name)
190-
for u in c.participations],
191+
for u in c.participations],
191192
usernames_and_last_names)
192193

193194
def assertContestNotInDb(self, name):

0 commit comments

Comments
 (0)