Skip to content

Commit e93273e

Browse files
committed
Add contest-export script
1 parent fc7db02 commit e93273e

File tree

2 files changed

+210
-1
lines changed

2 files changed

+210
-1
lines changed

misc-tools/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ TARGETS =
1010
OBJECTS =
1111

1212
SUBST_DOMSERVER = fix_permissions configure-domjudge dj_utils.py \
13-
import-contest force-passwords
13+
import-contest export-contest force-passwords
1414

1515
SUBST_JUDGEHOST = dj_make_chroot dj_run_chroot dj_make_chroot_docker \
1616
dj_judgehost_cleanup

misc-tools/export-contest.in

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env python3
2+
3+
'''
4+
export-contest -- Convenience script to export a contest (including metadata,
5+
teams and problems) from the command line. Defaults to using the CLI interface;
6+
Specify a DOMjudge API URL as to use that.
7+
8+
Reads credentials from ~/.netrc when using the API.
9+
10+
Part of the DOMjudge Programming Contest Jury System and licensed
11+
under the GNU GPL. See README and COPYING for details.
12+
'''
13+
14+
import datetime
15+
import json
16+
import os
17+
import sys
18+
import time
19+
from argparse import ArgumentParser
20+
from concurrent.futures import ThreadPoolExecutor, as_completed
21+
22+
sys.path.append('@domserver_libdir@')
23+
import dj_utils
24+
25+
mime_to_extension = {
26+
'application/pdf': 'pdf',
27+
'application/zip': 'zip',
28+
'image/jpeg': 'jpg',
29+
'image/png': 'png',
30+
'image/svg+xml': 'svg',
31+
'text/plain': 'txt',
32+
'video/mp4': 'mp4',
33+
'video/mpeg': 'mpg',
34+
'video/webm': 'webm',
35+
}
36+
37+
def get_default_contest():
38+
c_default = None
39+
40+
contests = dj_utils.do_api_request('contests')
41+
if len(contests)>0:
42+
now = int(time.time())
43+
for c in contests:
44+
if 'start_time' not in c or c['start_time'] is None:
45+
# Assume that a contest with start time unset will start soon.
46+
c['start_epoch'] = now + 1
47+
else:
48+
c['start_epoch'] = datetime.datetime.fromisoformat(c['start_time']).timestamp()
49+
50+
c_default = contests[0]
51+
for c in contests:
52+
if c_default['start_epoch']<=now:
53+
if c['start_epoch']<=now and c['start_epoch']>c_default['start_epoch']:
54+
c_default = c
55+
else:
56+
if c['start_epoch']<c_default['start_epoch']:
57+
c_default = c
58+
59+
return c_default
60+
61+
62+
def download_file(file: dict, dir: str, default_name: str):
63+
print(f"Downloading '{file['href']}'")
64+
os.makedirs(dir, exist_ok=True)
65+
filename = file['filename'] if 'filename' in file else default_name
66+
dj_utils.do_api_request(file['href'], decode=False, output_file=f'{dir}/{filename}')
67+
68+
69+
def is_file(data) -> bool:
70+
'''
71+
Check whether API `data` represents a FILE object. This is heuristic because
72+
no property is strictly required, but we need at least `href` to download
73+
the file, so if also we find one other property, we announce a winner.
74+
'''
75+
if not isinstance(data, dict):
76+
return false
77+
return 'href' in data and ('mime' in data or 'filename' in data or 'hash' in data)
78+
79+
80+
files_to_download = []
81+
82+
def recurse_find_files(data, store_path: str, default_name: str = None):
83+
'''
84+
Find all objects of FILE type in `data` representing a single Contest API
85+
entity, download these from their `href` and save them locally on disk at
86+
`store_path`. If the file object does not have a `filename` specified,
87+
then use `default_name`, which gets recursively constructed from the JSON
88+
path where the file object is located.
89+
90+
For example, if an organizations contains a single logo file, it will be
91+
stored at <store_path>/logo.jpg, but if it has multiple, then they will
92+
be stored at <store_path>/logo.<i>.jpg.
93+
'''
94+
if isinstance(data, list):
95+
# Special case single element list for simpler default_name
96+
if len(data) == 1:
97+
recurse_find_files(data[0], store_path, default_name)
98+
else:
99+
default_name = '' if default_name is None else default_name + '.'
100+
for i, item in enumerate(data):
101+
recurse_find_files(item, store_path, default_name + str(i))
102+
elif isinstance(data, dict):
103+
if is_file(data):
104+
if 'mime' in data and data['mime'] in mime_to_extension:
105+
default_name += '.' + mime_to_extension[data['mime']]
106+
files_to_download.append((data, store_path, default_name))
107+
else:
108+
default_name = '' if default_name is None else default_name + '.'
109+
for key, item in data.items():
110+
recurse_find_files(item, store_path, default_name + key)
111+
112+
113+
def download_endpoint(name: str, path: str):
114+
'''
115+
Download endpoint `name` from `path` relative to the base URL of the API.
116+
The name is determines the local filename to store it, and directory under
117+
which to store any related files
118+
'''
119+
ext = '.ndjson' if name == 'event-feed' else '.json'
120+
filename = name + ext
121+
122+
print(f"Fetching '{path}' to '{filename}'")
123+
data = dj_utils.do_api_request(path, decode=False)
124+
with open(filename, 'wb') as f:
125+
f.write(data)
126+
127+
if ext == '.json':
128+
# All data in the event feed should already be in the other
129+
# API endpoints. Also, we can't directly load it as JSON.
130+
data = json.loads(data)
131+
if isinstance(data, list):
132+
for elem in data:
133+
recurse_find_files(elem, f"{name}/{elem['id']}")
134+
else:
135+
recurse_find_files(data, name)
136+
137+
138+
cid = None
139+
dir = None
140+
141+
parser = ArgumentParser(description='Export a contest archive from DOMjudge via the API.')
142+
parser.add_argument('-c', '--cid', help="contest ID to export, defaults to last started, or else first non-started active contest")
143+
parser.add_argument('-d', '--dir', help="directory to write the contest archive to, defaults to contest ID in current directory")
144+
parser.add_argument('-u', '--url', help="DOMjudge API URL to use, if not specified use the CLI interface")
145+
args = parser.parse_args()
146+
147+
if args.cid:
148+
cid = args.cid
149+
else:
150+
c = get_default_contest()
151+
if c is None:
152+
print("No contest specified nor an active contest found.")
153+
exit(1)
154+
else:
155+
cid = c['id']
156+
157+
if args.dir:
158+
dir = args.dir
159+
else:
160+
dir = cid
161+
162+
if args.url:
163+
dj_utils.domjudge_api_url = args.url
164+
165+
user_data = dj_utils.do_api_request('user')
166+
if 'admin' not in user_data['roles']:
167+
print('Your user does not have the \'admin\' role, can not export.')
168+
exit(1)
169+
170+
if os.path.exists(dir):
171+
print(f'Export directory \'{dir}\' already exists, will not overwrite.')
172+
exit(1)
173+
174+
os.makedirs(dir)
175+
os.chdir(dir)
176+
177+
contest_path = f'contests/{cid}'
178+
179+
# Custom endpoints:
180+
download_endpoint('api', '')
181+
download_endpoint('contest', contest_path)
182+
download_endpoint('event-feed', f'{contest_path}/event-feed?stream=false')
183+
184+
for endpoint in [
185+
'access',
186+
'accounts',
187+
'awards',
188+
# 'balloons', This is a DOMjudge specific endpoint
189+
'clarifications',
190+
# 'commentary', Not implemented in DOMjudge
191+
'groups',
192+
'judgement-types',
193+
'judgements',
194+
'languages',
195+
'organizations',
196+
# 'persons', Not implemented in DOMjudge
197+
'problems',
198+
'runs',
199+
'scoreboard',
200+
'state',
201+
'submissions',
202+
'teams',
203+
]:
204+
download_endpoint(endpoint, f"{contest_path}/{endpoint}")
205+
206+
with ThreadPoolExecutor(20) as executor:
207+
futures = [executor.submit(download_file, *item) for item in files_to_download]
208+
for future in as_completed(futures):
209+
future.result() # So it can throw any exception

0 commit comments

Comments
 (0)