Skip to content

Commit 293c527

Browse files
committed
Add a simple API for automated interaction with CMS.
This adds 4 API endpoints: - A login endpoint that does not return HTML - An endpoint for programmatic access to the list of tasks, their statements, and the submission format - An endpoint for programmatic submission - An endpoint to get the list of submissions on a task. Together with the existing submission details APIs, this should allow automatic interaction with CMS.
1 parent ba3762f commit 293c527

File tree

3 files changed

+186
-21
lines changed

3 files changed

+186
-21
lines changed

cms/server/contest/handlers/__init__.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,24 @@
2323
# You should have received a copy of the GNU Affero General Public License
2424
# along with this program. If not, see <http://www.gnu.org/licenses/>.
2525

26-
from .communication import \
27-
CommunicationHandler, \
28-
QuestionHandler
26+
from .taskusertest import \
27+
UserTestInterfaceHandler, \
28+
UserTestHandler, \
29+
UserTestStatusHandler, \
30+
UserTestDetailsHandler, \
31+
UserTestIOHandler, \
32+
UserTestFileHandler
33+
from .tasksubmission import \
34+
SubmitHandler, \
35+
TaskSubmissionsHandler, \
36+
SubmissionStatusHandler, \
37+
SubmissionDetailsHandler, \
38+
SubmissionFileHandler, \
39+
UseTokenHandler
40+
from .task import \
41+
TaskDescriptionHandler, \
42+
TaskStatementViewHandler, \
43+
TaskAttachmentViewHandler
2944
from .main import \
3045
LoginHandler, \
3146
LogoutHandler, \
@@ -34,24 +49,14 @@
3449
NotificationsHandler, \
3550
PrintingHandler, \
3651
DocumentationHandler
37-
from .task import \
38-
TaskDescriptionHandler, \
39-
TaskStatementViewHandler, \
40-
TaskAttachmentViewHandler
41-
from .tasksubmission import \
42-
SubmitHandler, \
43-
TaskSubmissionsHandler, \
44-
SubmissionStatusHandler, \
45-
SubmissionDetailsHandler, \
46-
SubmissionFileHandler, \
47-
UseTokenHandler
48-
from .taskusertest import \
49-
UserTestInterfaceHandler, \
50-
UserTestHandler, \
51-
UserTestStatusHandler, \
52-
UserTestDetailsHandler, \
53-
UserTestIOHandler, \
54-
UserTestFileHandler
52+
from .communication import \
53+
CommunicationHandler, \
54+
QuestionHandler
55+
from .api import \
56+
ApiLoginHandler, \
57+
ApiSubmissionListHandler, \
58+
ApiSubmitHandler, \
59+
ApiTaskListHandler
5560

5661

5762
HANDLERS = [
@@ -97,6 +102,12 @@
97102
(r"/communication", CommunicationHandler),
98103
(r"/question", QuestionHandler),
99104

105+
# API
106+
(r"/api/login", ApiLoginHandler),
107+
(r"/api/task_list", ApiTaskListHandler),
108+
(r"/api/(.*)/submit", ApiSubmitHandler),
109+
(r"/api/(.*)/submission_list", ApiSubmissionListHandler),
110+
100111
# The following prefixes are handled by WSGI middlewares:
101112
# * /static, defined in cms/io/web_service.py
102113
# * /docs, defined in cms/server/contest/server.py

cms/server/contest/handlers/api.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
"""API handlers for CMS.
20+
21+
"""
22+
23+
import ipaddress
24+
import logging
25+
26+
try:
27+
import tornado4.web as tornado_web
28+
except ImportError:
29+
import tornado.web as tornado_web
30+
31+
from cms.db.submission import Submission
32+
from cms.server import multi_contest
33+
from cms.server.contest.authentication import validate_login
34+
from cms.server.contest.submission import \
35+
UnacceptableSubmission, accept_submission
36+
from .contest import ContestHandler
37+
from ..phase_management import actual_phase_required
38+
39+
logger = logging.getLogger(__name__)
40+
41+
42+
class ApiLoginHandler(ContestHandler):
43+
"""Login handler.
44+
45+
"""
46+
@multi_contest
47+
def post(self):
48+
username = self.get_argument("username", "")
49+
password = self.get_argument("password", "")
50+
51+
try:
52+
ip_address = ipaddress.ip_address(self.request.remote_ip)
53+
except ValueError:
54+
logger.warning("Invalid IP address provided by Tornado: %s",
55+
self.request.remote_ip)
56+
return None
57+
58+
participation, login_data = validate_login(
59+
self.sql_session, self.contest, self.timestamp, username, password,
60+
ip_address)
61+
62+
if participation is None:
63+
self.json({"error": "Login failed"}, 403)
64+
elif login_data is not None:
65+
cookie_name = self.contest.name + "_login"
66+
self.json({"login_data": self.create_signed_value(
67+
cookie_name, login_data).decode()})
68+
else:
69+
self.json({})
70+
71+
def check_xsrf_cookie(self):
72+
pass
73+
74+
75+
class ApiTaskListHandler(ContestHandler):
76+
"""Handler to list all tasks and their statements.
77+
78+
"""
79+
@tornado_web.authenticated
80+
@actual_phase_required(0, 3)
81+
@multi_contest
82+
def get(self):
83+
contest = self.contest
84+
tasks = []
85+
for task in contest.tasks:
86+
name = task.name
87+
statements = [s for s in task.statements]
88+
sub_format = task.submission_format
89+
tasks.append({"name": name,
90+
"statements": statements,
91+
"submission_format": sub_format})
92+
self.json({"tasks": tasks})
93+
94+
95+
class ApiSubmitHandler(ContestHandler):
96+
"""Handles the received submissions.
97+
98+
"""
99+
@tornado_web.authenticated
100+
@actual_phase_required(0, 3)
101+
@multi_contest
102+
def post(self, task_name: str):
103+
task = self.get_task(task_name)
104+
if task is None:
105+
self.json({"error": "Not found"}, 404)
106+
return
107+
108+
# Only set the official bit when the user can compete and we are not in
109+
# analysis mode.
110+
official = self.r_params["actual_phase"] == 0
111+
112+
try:
113+
submission = accept_submission(
114+
self.sql_session, self.service.file_cacher, self.current_user,
115+
task, self.timestamp, self.request.files,
116+
self.get_argument("language", None), official)
117+
self.sql_session.commit()
118+
except UnacceptableSubmission as e:
119+
logger.info("API submission rejected: `%s' - `%s'",
120+
e.subject, e.formatted_text)
121+
self.json({"error": e.subject, "details": e.formatted_text}, 400)
122+
else:
123+
logger.info(
124+
f'API submission accepted: Submission ID {submission.id}')
125+
self.service.evaluation_service.new_submission(
126+
submission_id=submission.id)
127+
self.json({'id': submission.opaque_id})
128+
129+
130+
class ApiSubmissionListHandler(ContestHandler):
131+
"""Retrieves the list of submissions on a task.
132+
133+
"""
134+
@tornado_web.authenticated
135+
@actual_phase_required(0, 3)
136+
@multi_contest
137+
def get(self, task_name: str):
138+
task = self.get_task(task_name)
139+
if task is None:
140+
self.json({"error": "Not found"}, 404)
141+
return
142+
submissions: list[Submission] = (
143+
self.sql_session.query(Submission)
144+
.filter(Submission.participation == self.current_user)
145+
.filter(Submission.task == task)
146+
.all()
147+
)
148+
self.json({'list': [{"id": s.opaque_id} for s in submissions]})

cms/server/contest/handlers/contest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"""
3131

3232
import ipaddress
33+
import json
3334
import logging
3435

3536
import collections
@@ -314,6 +315,11 @@ def notify_warning(
314315
def notify_error(self, subject: str, text: str, text_params: object | None = None):
315316
self.add_notification(subject, text, NOTIFICATION_ERROR, text_params)
316317

318+
def json(self, data, status_code=200):
319+
self.set_header("Content-type", "application/json; charset=utf-8")
320+
self.set_status(status_code)
321+
self.write(json.dumps(data))
322+
317323
def check_xsrf_cookie(self):
318324
# We don't need to check for xsrf if the request came with a custom
319325
# header, as those are not set by the browser.

0 commit comments

Comments
 (0)