Skip to content

Commit f55ed07

Browse files
committed
Add contest-export script
1 parent 62643cb commit f55ed07

File tree

2 files changed

+191
-1
lines changed

2 files changed

+191
-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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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):
83+
if isinstance(data, list):
84+
# Special case single element list for simpler default_name
85+
if len(data) == 1:
86+
recurse_find_files(data[0], store_path, default_name)
87+
else:
88+
for i, item in enumerate(data):
89+
recurse_find_files(item, store_path, f"{default_name}.{i}")
90+
elif isinstance(data, dict):
91+
if is_file(data):
92+
if 'mime' in data and data['mime'] in mime_to_extension:
93+
default_name += '.' + mime_to_extension[data['mime']]
94+
files_to_download.append((data, store_path, default_name))
95+
else:
96+
for key, item in data.items():
97+
recurse_find_files(item, store_path, f"{default_name}.{key}")
98+
99+
100+
def download_endpoint(name: str, path: str):
101+
ext = '.ndjson' if name == 'event-feed' else '.json'
102+
filename = name + ext
103+
104+
print(f"Fetching '{path}' to '{filename}'")
105+
data = dj_utils.do_api_request(path, decode=False)
106+
with open(filename, 'wb') as f:
107+
f.write(data)
108+
109+
if ext == '.json':
110+
data = json.loads(data)
111+
store_path = name
112+
if isinstance(data, list):
113+
for elem in data:
114+
recurse_find_files(elem, f"{store_path}/{elem['id']}", '')
115+
else:
116+
recurse_find_files(data, store_path, '')
117+
118+
119+
cid = None
120+
dir = None
121+
122+
parser = ArgumentParser(description='Export a contest archive from DOMjudge via the API.')
123+
parser.add_argument('-c', '--cid', help="contest ID to export, defaults to last started, or else first non-started active contest")
124+
parser.add_argument('-d', '--dir', help="directory to write the contest archive to, defaults to contest ID in current directory")
125+
parser.add_argument('-u', '--url', help="DOMjudge API URL to use, if not specified use the CLI interface")
126+
args = parser.parse_args()
127+
128+
if args.cid:
129+
cid = args.cid
130+
else:
131+
c = get_default_contest()
132+
if c is None:
133+
print("No contest specified nor an active contest found.")
134+
exit(1)
135+
else:
136+
cid = c['id']
137+
138+
if args.dir:
139+
dir = args.dir
140+
else:
141+
dir = cid
142+
143+
if args.url:
144+
dj_utils.domjudge_api_url = args.url
145+
146+
user_data = dj_utils.do_api_request('user')
147+
if 'admin' not in user_data['roles']:
148+
print('Your user does not have the \'admin\' role, can not export.')
149+
exit(1)
150+
151+
if os.path.exists(dir):
152+
print(f'Export directory \'{dir}\' already exists, will not overwrite.')
153+
exit(1)
154+
155+
os.makedirs(dir)
156+
os.chdir(dir)
157+
158+
contest_path = f'contests/{cid}'
159+
160+
# Custom endpoints:
161+
download_endpoint('api', '')
162+
download_endpoint('contest', contest_path)
163+
download_endpoint('event-feed', f'{contest_path}/event-feed?stream=false')
164+
165+
for endpoint in [
166+
'access',
167+
'accounts',
168+
'awards',
169+
# 'balloons', This is a DOMjudge specific endpoint
170+
'clarifications',
171+
# 'commentary', Not implemented in DOMjudge
172+
'groups',
173+
'judgement-types',
174+
'judgements',
175+
'languages',
176+
'organizations',
177+
# 'persons', Not implemented in DOMjudge
178+
'problems',
179+
'runs',
180+
'scoreboard',
181+
'state',
182+
'submissions',
183+
'teams',
184+
]:
185+
download_endpoint(endpoint, f"{contest_path}/{endpoint}")
186+
187+
with ThreadPoolExecutor(20) as executor:
188+
futures = [executor.submit(download_file, *item) for item in files_to_download]
189+
for future in as_completed(futures):
190+
future.result() # So it can throw any exception

0 commit comments

Comments
 (0)