|
| 1 | +import os |
| 2 | +import logging |
| 3 | +from typing import ( |
| 4 | + NamedTuple, |
| 5 | + Mapping, |
| 6 | + Any, |
| 7 | + Sequence, |
| 8 | + List, |
| 9 | + Optional, |
| 10 | + Dict, |
| 11 | + Set, |
| 12 | +) |
| 13 | +import omegaup.api |
| 14 | +import repository |
| 15 | +import json |
| 16 | +import datetime |
| 17 | +import yaml |
| 18 | + |
| 19 | +_CONFIG_FILE = 'contest.yaml' |
| 20 | + |
| 21 | + |
| 22 | +class Contest(NamedTuple): |
| 23 | + """Represents a single contest.""" |
| 24 | + path: str |
| 25 | + title: str |
| 26 | + config: Mapping[str, Any] |
| 27 | + |
| 28 | + @staticmethod |
| 29 | + def load(contestPath: str, rootDirectory: str) -> 'Contest': |
| 30 | + """Load a single contest from the path.""" |
| 31 | + with open(os.path.join(rootDirectory, contestPath, _CONFIG_FILE)) as f: |
| 32 | + problemConfig = yaml.safe_load(f) |
| 33 | + |
| 34 | + return Contest(path=contestPath, |
| 35 | + title=problemConfig['title'], |
| 36 | + config=problemConfig) |
| 37 | + |
| 38 | + |
| 39 | +def contests(allContests: bool = False, |
| 40 | + contestPaths: Sequence[str] = (), |
| 41 | + rootDirectory: Optional[str] = None) -> List[Contest]: |
| 42 | + """Gets the list of contests that will be considered. |
| 43 | +
|
| 44 | + If `allContests` is passed, all the contests that are declared in |
| 45 | + `contests.json` will be returned. Otherwise, only those that have |
| 46 | + differences with `upstream/main`. |
| 47 | + """ |
| 48 | + if rootDirectory is None: |
| 49 | + rootDirectory = repository.repositoryRoot() |
| 50 | + |
| 51 | + logging.info('Loading contests...') |
| 52 | + |
| 53 | + if contestPaths: |
| 54 | + # Generate the Contest objects from just the path. The title is ignored |
| 55 | + # anyways, since it's read from the configuration file in the contest |
| 56 | + # directory for anything important. |
| 57 | + return [ |
| 58 | + Contest.load(contestPath=contestPath, rootDirectory=rootDirectory) |
| 59 | + for contestPath in contestPaths |
| 60 | + ] |
| 61 | + |
| 62 | + with open(os.path.join(rootDirectory, 'problems.json'), 'r') as p: |
| 63 | + config = json.load(p) |
| 64 | + |
| 65 | + configContests: List[Contest] = [] |
| 66 | + for contest in config['contests']: |
| 67 | + if contest.get('disabled', False): |
| 68 | + logging.warning('Contest %s disabled. Skipping.', contest['title']) |
| 69 | + continue |
| 70 | + configContests.append( |
| 71 | + Contest.load(contestPath=contest['path'], |
| 72 | + rootDirectory=rootDirectory)) |
| 73 | + |
| 74 | + if allContests: |
| 75 | + logging.info('Loading everything as requested.') |
| 76 | + return configContests |
| 77 | + |
| 78 | + changes = repository.gitDiff(rootDirectory) |
| 79 | + |
| 80 | + contests: List[Contest] = [] |
| 81 | + for contest in configContests: |
| 82 | + logging.info('Loading %s.', contest.title) |
| 83 | + |
| 84 | + if contest.path not in changes: |
| 85 | + logging.info('No changes to %s. Skipping.', contest.title) |
| 86 | + continue |
| 87 | + contests.append(contest) |
| 88 | + |
| 89 | + return contests |
| 90 | + |
| 91 | + |
| 92 | +def date_to_timestamp(date: str) -> int: |
| 93 | + return int( |
| 94 | + datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%SZ').timestamp()) |
| 95 | + |
| 96 | + |
| 97 | +def upsertContest( |
| 98 | + client: omegaup.api.Client, |
| 99 | + contestPath: str, |
| 100 | + canCreate: bool, |
| 101 | + timeout: datetime.timedelta, |
| 102 | +) -> None: |
| 103 | + """Upsert a contest to omegaUp given the configuration.""" |
| 104 | + with open(os.path.join(contestPath, _CONFIG_FILE)) as f: |
| 105 | + contestConfig = yaml.safe_load(f) |
| 106 | + |
| 107 | + logging.info('Upserting contest %s...', contestConfig['title']) |
| 108 | + |
| 109 | + title = contestConfig['title'] |
| 110 | + alias = contestConfig['alias'] |
| 111 | + misc = contestConfig['misc'] |
| 112 | + languages = misc['languages'] |
| 113 | + |
| 114 | + if languages == 'all': |
| 115 | + misc['languages'] = ','.join(( |
| 116 | + 'c11-clang', |
| 117 | + 'c11-gcc', |
| 118 | + 'cpp11-clang', |
| 119 | + 'cpp11-gcc', |
| 120 | + 'cpp17-clang', |
| 121 | + 'cpp17-gcc', |
| 122 | + 'cpp20-clang', |
| 123 | + 'cpp20-gcc', |
| 124 | + 'cs', |
| 125 | + 'go', |
| 126 | + 'hs', |
| 127 | + 'java', |
| 128 | + 'js', |
| 129 | + 'kt', |
| 130 | + 'lua', |
| 131 | + 'pas', |
| 132 | + 'py2', |
| 133 | + 'py3', |
| 134 | + 'rb', |
| 135 | + 'rs', |
| 136 | + )) |
| 137 | + elif languages == 'karel': |
| 138 | + misc['languages'] = 'kj,kp' |
| 139 | + elif languages == 'none': |
| 140 | + misc['languages'] = '' |
| 141 | + |
| 142 | + payload = { |
| 143 | + 'title': title, |
| 144 | + 'admission_mode': misc['admission_mode'], |
| 145 | + 'description': contestConfig.get('description', ''), |
| 146 | + 'feedback': misc["feedback"], |
| 147 | + 'finish_time': date_to_timestamp(contestConfig['finish_time']), |
| 148 | + 'languages': misc['languages'], |
| 149 | + 'penalty': misc['penalty']['time'], |
| 150 | + 'penalty_calc_policy': misc['penalty']['calc_policy'], |
| 151 | + 'penalty_type': misc['penalty']['type'], |
| 152 | + 'points_decay_factor': misc['penalty']['points_decay_factor'], |
| 153 | + 'requests_user_information': str(misc['requests_user_information']), |
| 154 | + 'score_mode': misc['score_mode'], |
| 155 | + 'scoreboard': misc['scoreboard'], |
| 156 | + 'show_scoreboard_after': misc['show_scoreboard_after'], |
| 157 | + 'submissions_gap': misc['submissions_gap'], |
| 158 | + 'start_time': date_to_timestamp(contestConfig['start_time']), |
| 159 | + 'window_length': contestConfig.get('window_length', None), |
| 160 | + } |
| 161 | + |
| 162 | + exists = client.contest.details(contest_alias=alias, |
| 163 | + check_=False)["status"] == 'ok' |
| 164 | + |
| 165 | + if not exists: |
| 166 | + if not canCreate: |
| 167 | + raise Exception("Contest doesn't exist!") |
| 168 | + logging.info("Contest doesn't exist. Creating contest.") |
| 169 | + endpoint = '/api/contest/create/' |
| 170 | + payload['alias'] = alias |
| 171 | + else: |
| 172 | + endpoint = '/api/contest/update/' |
| 173 | + payload['contest_alias'] = alias |
| 174 | + |
| 175 | + client.query(endpoint, payload, timeout_=timeout) |
| 176 | + |
| 177 | + # Adding admins |
| 178 | + targetAdmins: Sequence[str] = contestConfig.get('admins', |
| 179 | + {}).get('users', []) |
| 180 | + targetAdminGroups: Sequence[str] = contestConfig.get('admins', |
| 181 | + {}).get('groups', []) |
| 182 | + |
| 183 | + allAdmins = client.contest.admins(contest_alias=alias) |
| 184 | + |
| 185 | + if len(targetAdmins) > 0: |
| 186 | + admins = { |
| 187 | + a['username'].lower() |
| 188 | + for a in allAdmins['admins'] if a['role'] == 'admin' |
| 189 | + } |
| 190 | + |
| 191 | + desiredAdmins = {admin.lower() for admin in targetAdmins} |
| 192 | + |
| 193 | + clientAdmin: Set[str] = set() |
| 194 | + if client.username: |
| 195 | + clientAdmin.add(client.username.lower()) |
| 196 | + adminsToRemove = admins - desiredAdmins - clientAdmin |
| 197 | + adminsToAdd = desiredAdmins - admins - clientAdmin |
| 198 | + |
| 199 | + for admin in adminsToAdd: |
| 200 | + logging.info('Adding contest admin: %s', admin) |
| 201 | + client.contest.addAdmin(contest_alias=alias, usernameOrEmail=admin) |
| 202 | + |
| 203 | + for admin in adminsToRemove: |
| 204 | + logging.info('Removing contest admin: %s', admin) |
| 205 | + client.contest.removeAdmin(contest_alias=alias, |
| 206 | + usernameOrEmail=admin) |
| 207 | + |
| 208 | + adminGroups = { |
| 209 | + a['alias'].lower() |
| 210 | + for a in allAdmins['group_admins'] if a['role'] == 'admin' |
| 211 | + } |
| 212 | + |
| 213 | + desiredGroups = {group.lower() for group in targetAdminGroups} |
| 214 | + |
| 215 | + groupsToRemove = adminGroups - desiredGroups |
| 216 | + groupsToAdd = desiredGroups - adminGroups |
| 217 | + |
| 218 | + for group in groupsToAdd: |
| 219 | + logging.info('Adding contest admin group: %s', group) |
| 220 | + client.contest.addGroupAdmin(contest_alias=alias, group=group) |
| 221 | + |
| 222 | + for group in groupsToRemove: |
| 223 | + logging.info('Removing contest admin group: %s', group) |
| 224 | + client.contest.removeGroupAdmin(contest_alias=alias, group=group) |
| 225 | + |
| 226 | + # Adding problems |
| 227 | + targetProblems: Sequence[Dict[str, |
| 228 | + Any]] = contestConfig.get('problems', []) |
| 229 | + |
| 230 | + allProblems = client.contest.problems(contest_alias=alias) |
| 231 | + problems = { |
| 232 | + p['alias'].lower(): { |
| 233 | + 'points': p['points'], |
| 234 | + 'order_in_contest': p['order'], |
| 235 | + } |
| 236 | + for p in allProblems['problems'] |
| 237 | + } |
| 238 | + |
| 239 | + desiredProblems = { |
| 240 | + problem['alias'].lower(): { |
| 241 | + 'points': problem.get('points', 100), |
| 242 | + 'order_in_contest': problem.get('order_in_contest', idx + 1), |
| 243 | + } |
| 244 | + for idx, problem in enumerate(targetProblems) |
| 245 | + } |
| 246 | + problemsToRemove = problems.keys() - desiredProblems |
| 247 | + problemsToUpsert = (desiredProblems.keys() - problems.keys()) | { |
| 248 | + problem |
| 249 | + for problem in problems |
| 250 | + if problem in problems and problem in desiredProblems and |
| 251 | + (desiredProblems[problem] != problems[problem]) |
| 252 | + } |
| 253 | + |
| 254 | + for problem in problemsToUpsert: |
| 255 | + logging.info('Upserting contest problem: %s', problem) |
| 256 | + client.contest.addProblem( |
| 257 | + contest_alias=alias, |
| 258 | + problem_alias=problem, |
| 259 | + order_in_contest=desiredProblems[problem]['order_in_contest'], |
| 260 | + points=desiredProblems[problem]['points']) |
| 261 | + |
| 262 | + for problem in problemsToRemove: |
| 263 | + logging.info('Removing contest problem: %s', problem) |
| 264 | + client.contest.removeProblem(contest_alias=alias, |
| 265 | + problem_alias=problem) |
| 266 | + |
| 267 | + # Adding contestants |
| 268 | + targetContestants: Sequence[str] = contestConfig.get('contestants', |
| 269 | + {}).get('users', []) |
| 270 | + targetContestantGroups: Sequence[str] = contestConfig.get( |
| 271 | + 'contestants', {}).get('groups', []) |
| 272 | + |
| 273 | + allContestants = client.contest.users(contest_alias=alias) |
| 274 | + |
| 275 | + if len(targetContestants) > 0: |
| 276 | + contestants = {c['username'].lower() for c in allContestants['users']} |
| 277 | + |
| 278 | + desiredContestants = { |
| 279 | + contestant.lower() |
| 280 | + for contestant in targetContestants |
| 281 | + } |
| 282 | + |
| 283 | + contestantsToRemove = contestants - desiredContestants |
| 284 | + contestantsToAdd = desiredContestants - contestants |
| 285 | + |
| 286 | + for contestant in contestantsToAdd: |
| 287 | + logging.info('Adding contestant: %s', contestant) |
| 288 | + client.contest.addUser(contest_alias=alias, |
| 289 | + usernameOrEmail=contestant) |
| 290 | + |
| 291 | + for contestant in contestantsToRemove: |
| 292 | + logging.info('Removing contestant: %s', contestant) |
| 293 | + client.contest.removeUser(contest_alias=alias, |
| 294 | + usernameOrEmail=contestant) |
| 295 | + |
| 296 | + contestantGroups = {c['alias'].lower() for c in allContestants['groups']} |
| 297 | + |
| 298 | + desiredGroups = {group.lower() for group in targetContestantGroups} |
| 299 | + |
| 300 | + groupsToRemove = contestantGroups - desiredGroups |
| 301 | + groupsToAdd = desiredGroups - contestantGroups |
| 302 | + |
| 303 | + for group in groupsToAdd: |
| 304 | + logging.info('Adding contestant group: %s', group) |
| 305 | + client.contest.addGroup(contest_alias=alias, group=group) |
| 306 | + |
| 307 | + for group in groupsToRemove: |
| 308 | + logging.info('Removing contestant group: %s', group) |
| 309 | + client.contest.removeGroup(contest_alias=alias, group=group) |
| 310 | + |
| 311 | + logging.info("Successfully upserted contest %s", title) |
0 commit comments