ctfy.sdk.admin_resources.competitions
client.admin.competitions — competition CRUD + roster (admin).
1"""``client.admin.competitions`` — competition CRUD + roster (admin).""" 2 3from __future__ import annotations 4 5import builtins 6from datetime import datetime 7from typing import Any, Literal 8 9from ctfy.core.challenge_source import ChallengeScope 10from ctfy.core.enums import ( 11 DEFAULT_SCORING_RULE_VERSION, 12 InternetAccess, 13 InternetPolicy, 14 ScoringRuleName, 15 ScoringRuleVersion, 16 SessionEndMode, 17 SolveEnvMode, 18 TcpAccess, 19) 20from ctfy.core.localized import LocalizedText 21from ctfy.sdk._helpers import _raise_for_status 22from ctfy.sdk.base import BaseHttpClient 23from ctfy.server.models import ( 24 AdminScoringChangeInfo, 25 AdminScoringChangesResponse, 26 AuthoringChecklistInfo, 27 AuthoringScaleInfo, 28 AuthoringSmokeLedgerInfo, 29 AwdMatchConfig, 30 CompetitionChallengeChangeInfo, 31 CompetitionChallengeOrder, 32 CompetitionChallengeSource, 33 CompetitionDriftInfo, 34 CompetitionFormatPreset, 35 CompetitionInfo, 36 CompetitionInternetApplied, 37 CompetitionInternetApplyRequest, 38 CompetitionInternetInfo, 39 CompetitionPinnedChallenges, 40 CompetitionPoolInfo, 41 CompetitionPreviewChallenges, 42 CompetitionRecords, 43 CompetitionRegistrationInfo, 44 CompetitionTcpAccessInfo, 45 RepriceRequest, 46 ScoringManifestInfo, 47 ScoringPreview, 48 ScoringPreviewChallenge, 49 ScoringPreviewRequest, 50) 51 52 53class AdminCompetitionsResource: 54 """Create / update / delete competitions and inspect their roster.""" 55 56 def __init__(self, http: BaseHttpClient) -> None: 57 self._http = http 58 59 def formats(self) -> builtins.list[CompetitionFormatPreset]: 60 """The wizard's named starting points for a new competition. 61 62 Each entry is a set of axis values, not a type: apply one to 63 ``create(...)`` and then override whatever the event needs. The 64 platform stores no record of which one was used. 65 """ 66 resp = self._http.request("GET", "/admin/competition-formats") 67 _raise_for_status(resp) 68 return [CompetitionFormatPreset.model_validate(f) for f in resp.json()] 69 70 def internet(self, competition_id: str) -> CompetitionInternetInfo: 71 """Every challenge here: the author's internet declaration and its verdict (#1969). 72 73 ``rows`` sorts the conflicts first — a challenge whose author says 74 it needs the internet and that this competition denies it — and 75 ``conflicts`` counts them. Change the policy or a per-challenge 76 override with :meth:`update` (``internet_policy`` / 77 ``challenge_internet``, the latter replaced whole). 78 """ 79 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet") 80 _raise_for_status(resp) 81 return CompetitionInternetInfo.model_validate(resp.json()) 82 83 def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied: 84 """Apply today's internet policy to the boxes already running (#2067). 85 86 The policy is read at start, so a change leaves running boxes as they 87 were (:meth:`internet`'s ``running_differs``). This moves each of them 88 to the verdict the policy gives today, through its node, and audits 89 each move with *reason*. Every box tried is in ``results`` with its 90 outcome; a failure keeps the box as it was. 91 """ 92 resp = self._http.request( 93 "POST", 94 f"/admin/competitions/{competition_id}/internet/apply", 95 json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"), 96 ) 97 _raise_for_status(resp) 98 return CompetitionInternetApplied.model_validate(resp.json()) 99 100 def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo: 101 """How players reach this competition's TCP challenges (#2082). 102 103 The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP 104 challenge's own way in, and how many live nodes can publish a port. 105 Change either with :meth:`update` (``tcp_access`` / 106 ``challenge_tcp_access``, the latter replaced whole). 107 """ 108 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access") 109 _raise_for_status(resp) 110 return CompetitionTcpAccessInfo.model_validate(resp.json()) 111 112 def pool(self, competition_id: str) -> CompetitionPoolInfo: 113 """Where this event's work runs: its dedicated node pool (#1965 E). 114 115 ``state`` is ``shared`` (no pool declared), ``ready`` or 116 ``no_live_node`` — the last meaning every start is refused and every 117 smoke waits, since nothing falls back to the shared pools. ``nodes`` 118 is every machine labelled ``competition=<id>``, live or not. Switch 119 the declaration with :meth:`update` (``dedicated_pool``). 120 """ 121 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool") 122 _raise_for_status(resp) 123 return CompetitionPoolInfo.model_validate(resp.json()) 124 125 def authoring_checklist(self, competition_id: str) -> AuthoringChecklistInfo: 126 """What must be right before this event opens automatic review (#1965 C3). 127 128 ``rows`` — the dedicated pool, the authoring windows, the contributor 129 agreement, the smoke quota, the internet policy — each ``ok`` or not, 130 with a ``reason`` code and the setting's ``value``; ``ready`` is all of 131 them. Advice: nothing refuses on it. Fix a row with :meth:`update`. 132 """ 133 resp = self._http.request( 134 "GET", f"/admin/competitions/{competition_id}/authoring-checklist" 135 ) 136 _raise_for_status(resp) 137 return AuthoringChecklistInfo.model_validate(resp.json()) 138 139 def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo: 140 """Every automatic smoke run charged in this competition, oldest first 141 (#1979 E3). 142 143 Each row says whose allowance it counted against (``purpose``: 144 ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's 145 day) and whether the platform refunded it (``refunded_at``, with the 146 reason) because the run reached no verdict. The whole set, unpaged. 147 """ 148 resp = self._http.request( 149 "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs" 150 ) 151 _raise_for_status(resp) 152 return AuthoringSmokeLedgerInfo.model_validate(resp.json()) 153 154 def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]: 155 """Every recorded change to this competition's scoring made while it 156 was live (#1965 H), oldest first — with who made it and the board 157 each one replaced. The public copy is 158 ``client.competitions.scoring_changes``.""" 159 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes") 160 _raise_for_status(resp) 161 return AdminScoringChangesResponse.model_validate(resp.json()).changes 162 163 def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo: 164 """The frozen scoring manifest (#2731 B): per frozen challenge, the 165 price inputs it was frozen with beside what the catalog says now, and 166 every re-price so far. ``frozen`` is ``False`` before the start.""" 167 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest") 168 _raise_for_status(resp) 169 return ScoringManifestInfo.model_validate(resp.json()) 170 171 def reprice_challenge( 172 self, competition_id: str, challenge_id: str, *, reason: str 173 ) -> ScoringManifestInfo: 174 """Re-price one frozen challenge from the catalog, with a recorded 175 reason (#2731 B) — the one door that moves a frozen price. Returns the 176 manifest as it now stands.""" 177 resp = self._http.request( 178 "POST", 179 f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice", 180 json=RepriceRequest(reason=reason).model_dump(mode="json"), 181 ) 182 _raise_for_status(resp) 183 return ScoringManifestInfo.model_validate(resp.json()) 184 185 def scoring_preview( 186 self, 187 scoring_rule: ScoringRuleName | Literal[""], 188 scoring_params: dict[str, Any], 189 challenges: builtins.list[ScoringPreviewChallenge], 190 *, 191 reference_id: str = "", 192 max_solvers: int = 100, 193 table_solvers: int = 10, 194 compare_curves: builtins.list[str] | None = None, 195 ) -> ScoringPreview: 196 """Price a **draft** scoring config with the board's own arithmetic. 197 198 What the scoring form's live curve calls: per-challenge values, the 199 value curve for ``reference_id`` (and any ``compare_curves``) and the 200 first-blood ladder. Nothing is stored, and a draft the board could 201 not run is refused as the write path refuses it. 202 """ 203 body = ScoringPreviewRequest( 204 scoring_rule=scoring_rule, 205 scoring_params=scoring_params, 206 challenges=challenges, 207 reference_id=reference_id, 208 max_solvers=max_solvers, 209 table_solvers=table_solvers, 210 compare_curves=compare_curves or [], 211 ) 212 resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump()) 213 _raise_for_status(resp) 214 return ScoringPreview.model_validate(resp.json()) 215 216 def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo: 217 """What this board's ``authoring_weight`` is worth, in challenges. 218 219 A ``peer_authoring`` weight is a fixed multiplier on a curve 220 output, so on its own it says nothing: 200 is generous on a board 221 of 100-point flags and a rounding error on one of 5000. This 222 prices it against the competition's own challenge set. 223 224 Read-only, and computed from the **stored** rule and challenge 225 set — a weight you have not saved is not what this answers about. 226 """ 227 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale") 228 _raise_for_status(resp) 229 return AuthoringScaleInfo.model_validate(resp.json()) 230 231 def records(self, competition_id: str) -> CompetitionRecords: 232 """Every record of one competition, for scoring outside the platform. 233 234 Teams (with each agent's owner and the owner's team), solves as the 235 board ranks them, submissions without their text, agent runs, and 236 uploads with their authors — complete, never a page. 237 """ 238 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records") 239 _raise_for_status(resp) 240 return CompetitionRecords.model_validate(resp.json()) 241 242 def list( 243 self, offset: int = 0, limit: int = 50, kind: str = "" 244 ) -> builtins.list[CompetitionInfo]: 245 """Every competition — past, current, scheduled. ``kind`` ∈ ``"" | 246 "event" | "suite"`` narrows it ("" = both).""" 247 params: dict[str, Any] = {"offset": offset, "limit": limit} 248 if kind: 249 params["kind"] = kind 250 resp = self._http.request("GET", "/admin/competitions", params=params) 251 _raise_for_status(resp) 252 return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]] 253 254 def create( 255 self, 256 *, 257 title: str | LocalizedText, 258 description: str | LocalizedText = "", 259 handbook: str | LocalizedText = "", 260 starts_at: datetime | None = None, 261 ends_at: datetime | None = None, 262 challenge_ids: builtins.list[str] | None = None, 263 challenge_scope: ChallengeScope = "set", 264 status: str = "draft", 265 access: str = "public", 266 kind: str = "event", 267 eval_standard: bool = False, 268 corpus_sha: str = "", 269 scoring_rule: str = "", 270 scoring_params: dict[str, Any] | None = None, 271 scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION, 272 sessions: builtins.list[tuple[datetime, datetime]] | None = None, 273 authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None, 274 awd_match: AwdMatchConfig | None = None, 275 entrants_are_agents: bool = False, 276 internet_policy: InternetPolicy = InternetPolicy.ALLOW, 277 challenge_internet: dict[str, InternetAccess] | None = None, 278 tcp_access: TcpAccess = TcpAccess.TLS_SNI, 279 challenge_tcp_access: dict[str, TcpAccess] | None = None, 280 session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP, 281 session_end_grace_s: int = 900, 282 solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE, 283 solve_grace_s: int = 900, 284 freeze_minutes: int = 0, 285 freeze_auto_unfreeze: bool = False, 286 ) -> CompetitionInfo: 287 """Create a new competition. ``title`` is a bare string — filed under 288 ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a 289 ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound 290 (sent as JSON ``null``). Unknown ``challenge_ids`` → 422. 291 ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the 292 default) — ``challenge_ids`` and nothing else, so an empty set starts 293 nothing — or ``"catalog"``, where any catalog challenge may start. 294 ``status`` defaults to ``draft`` (hidden from players); pass 295 ``"published"`` to make it immediately visible. ``access`` 296 defaults to ``public``; pass ``"private_listed"`` / 297 ``"private_hidden"`` for an invite-only competition. ``kind`` is 298 ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public 299 benchmark set open at any time: no window, no registration, listed on 300 the 测评集 page instead of the contest list; #2097). 301 ``eval_standard=True`` marks it for the platform's hosted evaluation 302 (only such a competition may back an official EvalCampaign); 303 ``corpus_sha`` pins the corpus snapshot it was certified against. 304 ``scoring_rule`` selects the board's ranking algorithm 305 (``""`` = flag count, ``static_points``, ``dynamic_points``) with 306 ``scoring_params`` carrying its tuning; set it here where you can, 307 because scoring freezes once the competition goes live. 308 ``scoring_rule_version`` is what a multi-flag challenge is worth 309 (#2731): ``challenge_total`` (the default) shares one total among 310 its scoring questions by weight; ``legacy`` pays every flag the 311 full value. It can be changed with :meth:`update` only before the 312 competition goes live (``409 scoring_rule_version_locked``). 313 ``sessions`` declares the playable slices of the window as 314 ``(start, end)`` pairs — a three-day event running 08:00-16:00 315 daily is three of them. Supplying it also *sets* ``starts_at`` / 316 ``ends_at``, which the server derives from the envelope, and 317 opts the competition into the play gate: outside a session, 318 submissions and instance starts are refused and running 319 instances are torn down. Omit it for a single continuous 320 window, which is how every competition behaves by default. 321 ``authoring_windows`` is the same idea for uploads: ``(start, 322 end)`` pairs, either side ``None`` for unbounded, outside which an 323 authored-challenge upload is refused — four mornings is four 324 pairs. Omit it to take uploads for as long as the event runs. 325 ``handbook`` is the organiser's long-form rules page, markdown, 326 capped server-side — ``description`` stays the one-line blurb the 327 listing and the page header truncate. Both are localised: pass a 328 ``{locale: text}`` map to write one translation, or a bare string, 329 which the server reads as ``en``. ⚠️ **A bare string is kept in the 330 signature rather than deprecated** — every caller that predates 331 localisation sends one, and a 422 for all of them would be a 332 breaking change dressed up as a feature; the coercion is the 333 server's (``coerce_localized``), so the two cannot disagree. 334 ``entrants_are_agents=True`` marks it an agent exhibition: human 335 registration is refused server-side and the browser shows a 336 spectate affordance rather than a sign-up button. 337 ``internet_policy`` decides whether instances started here may 338 reach the internet — ``allow`` (the default, today's behaviour), 339 ``deny``, or ``per_challenge`` (each challenge as its author 340 declared) — and ``challenge_internet`` overrides it per challenge 341 id (#1969). ``tcp_access`` is how players reach a TCP challenge — 342 ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and 343 ``challenge_tcp_access`` overrides it per challenge id (#2082). 344 ``session_end_mode`` decides what a play session's end does 345 to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace`` 346 (``session_end_grace_s``) / ``delete`` -- and the last session always 347 deletes (#2128). ``solve_env_mode`` decides what a full solve does to 348 the solved box -- ``destroy`` / ``grace`` (default, 349 ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the 350 players' scoreboard at ``ends_at - freeze_minutes`` until 351 :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event 352 ends); ``0`` never freezes (#2133).""" 353 resp = self._http.request( 354 "POST", 355 "/admin/competitions", 356 json={ 357 **({"title_i18n": title} if isinstance(title, dict) else {"title": title}), 358 "description": description, 359 "handbook": handbook, 360 "starts_at": starts_at.isoformat() if starts_at else None, 361 "ends_at": ends_at.isoformat() if ends_at else None, 362 "challenge_ids": challenge_ids or [], 363 "challenge_scope": challenge_scope, 364 "status": status, 365 "access": access, 366 "kind": kind, 367 "eval_standard": eval_standard, 368 "corpus_sha": corpus_sha, 369 "scoring_rule": scoring_rule, 370 "scoring_params": scoring_params or {}, 371 "scoring_rule_version": scoring_rule_version, 372 # An agent exhibition: entrants are dispatched agents, so 373 # human registration is refused and the browser offers a 374 # spectate affordance. See ``CompetitionCreate``. 375 "entrants_are_agents": entrants_are_agents, 376 # #1969: the internet axis and its per-challenge overrides. 377 "internet_policy": str(internet_policy), 378 "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()}, 379 # #2082: the way in for TCP challenges, and its overrides. 380 "tcp_access": str(tcp_access), 381 "challenge_tcp_access": { 382 k: str(v) for k, v in (challenge_tcp_access or {}).items() 383 }, 384 "session_end_mode": str(session_end_mode), 385 "session_end_grace_s": session_end_grace_s, 386 "solve_env_mode": str(solve_env_mode), 387 "solve_grace_s": solve_grace_s, 388 "freeze_minutes": freeze_minutes, 389 "freeze_auto_unfreeze": freeze_auto_unfreeze, 390 "sessions": [ 391 {"starts_at": s.isoformat(), "ends_at": e.isoformat()} 392 for s, e in (sessions or []) 393 ], 394 "authoring_windows": [ 395 { 396 "starts_at": s.isoformat() if s else None, 397 "ends_at": e.isoformat() if e else None, 398 } 399 for s, e in (authoring_windows or []) 400 ], 401 # ⚠️ **Absent, never `{}`.** The server's model is 402 # `extra="forbid"` with two required cadence fields, so 403 # an empty object is a 422 on every competition that 404 # runs no rounds. `None` here means "not configured in 405 # this request", which the upsert route can still 406 # satisfy tomorrow — never "this competition has no 407 # match". 408 **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}), 409 }, 410 ) 411 _raise_for_status(resp) 412 return CompetitionInfo.model_validate(resp.json()) 413 414 def update(self, competition_id: str, **fields: Any) -> CompetitionInfo: 415 """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}`` 416 title map, replaced; #2230) / ``title`` (a bare string, merged into the 417 locale it is written in) / ``description`` / ``starts_at`` / 418 ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` | 419 ``catalog``, #2553 — a change to what may be played, so a locked event 420 takes it only like a change to the set) / ``status`` / ``access`` / 421 ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` / 422 ``scoring_params`` / ``scoring_rule_version`` (before the start only) / 423 ``sessions`` / ``anonymous_entry`` / 424 ``anonymous_entry_kind`` / ``authoring_review`` (``human`` | 425 ``automatic``: under ``automatic`` a passing smoke on the event's 426 dedicated pool approves, publishes and curates an upload) / 427 ``authoring_smoke_quota`` (smoke runs per team per authoring window 428 under ``automatic``) / ``authoring_rate_limit_count`` + 429 ``authoring_rate_limit_window_s`` (uploads one team may make per 430 rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window`` 431 (new uploads one team may start per authoring window, a re-upload of its 432 own upload never counted; ``0`` is **unlimited**) / 433 ``pinned`` (pin / unpin the event for everybody, #2070 — **platform 434 admins only**, ``403 pin_requires_platform_admin`` for an organiser) / 435 ``organiser_contact_email`` (where entrants reach the organisers, shown 436 on the competition page and in the registration mail, #2283; ``""`` 437 removes it) / 438 ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the 439 platform's own authoring setting names, e.g. 440 ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move, 441 ``0`` defers to the platform; the runtime budget, build timeout, 442 auto-smoke budget and re-check interval are **platform admins only**, 443 ``403 authoring_override_requires_platform_admin``) / 444 ``authoring_cooldown`` (``default`` | 445 ``platform`` | ``competition`` | ``off``: the pause after repeated 446 rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` / 447 ``_pause_s`` read under ``competition``) / ``report_disclosure`` 448 (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what 449 players are shown about how they are doing, #2030) / ``freeze_minutes`` + 450 ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N 451 minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list 452 is what the wire accepts today, so it is a pointer to 453 ``CompetitionUpdate`` rather than a gate, and :meth:`create` is 454 the narrow one (it builds its body by hand and cannot carry a 455 field it does not name). ⚠️ ``anonymous_entry`` may be *widened* 456 freely and is refused on a narrowing once entrants have relied 457 on it, ``required`` → ``optional`` included: that transition 458 un-anonymises the whole field at once. Omit a field to leave it; pass 459 ``None`` to clear. ``sessions`` is the one field where ``[]`` is 460 meaningful rather than empty: it clears the schedule and returns 461 the competition to a single continuous window; ``authoring_windows`` 462 is the other, and ``[]`` there takes uploads for as long as the 463 event runs. Changing the scoring fields on a live competition needs 464 ``scoring_change_reason`` (``422 scoring_change_needs_reason`` 465 without one): the change is recorded and shown on the board — see 466 ``competitions.scoring_live``. ``lock_challenge_versions`` holds an 467 event's uploaded challenges still while it is in play (#2041); a 468 change to ``challenge_ids`` of such an event then needs 469 ``challenge_change_confirmed=True`` and a ``challenge_change_reason`` 470 (``409 challenge_set_locked`` otherwise), recorded per challenge — 471 see :meth:`challenge_changes`.""" 472 resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields) 473 _raise_for_status(resp) 474 return CompetitionInfo.model_validate(resp.json()) 475 476 def challenge_source(self, competition_id: str) -> CompetitionChallengeSource: 477 """Where a competition's challenge set comes from (#2234): the 478 competition it follows, whether that source passes its set on now 479 (only while it is published), and the competitions that follow it.""" 480 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source") 481 _raise_for_status(resp) 482 return CompetitionChallengeSource.model_validate(resp.json()) 483 484 def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo: 485 """Make a competition's challenge set follow ``source_id``'s, or stop 486 following with ``""`` (#2234). 487 488 The follower then serves the source's set, read live, minus player 489 uploads; its own list is cleared and not editable while it follows 490 (``409 challenge_set_followed``). Stopping keeps the set it serves at 491 that moment as its own list. One level only: refused with 492 ``challenge_source_chained`` / ``challenge_source_followed``; the 493 caller must organise the source (``403 challenge_source_forbidden``). 494 Solves, instances and boards stay each competition's own.""" 495 return self.update(competition_id, challenge_source_competition_id=source_id) 496 497 def reorder_challenges( 498 self, competition_id: str, challenge_ids: builtins.list[str] 499 ) -> CompetitionChallengeOrder: 500 """Put a competition's challenges in the order its entrants see them 501 (#2037). ``challenge_ids`` must be exactly the set it holds now, 502 reordered — ``competition_challenge_order_stale`` when another 503 organiser changed it meanwhile.""" 504 resp = self._http.request( 505 "PUT", 506 f"/admin/competitions/{competition_id}/challenge-order", 507 json={"challenge_ids": challenge_ids}, 508 ) 509 _raise_for_status(resp) 510 return CompetitionChallengeOrder.model_validate(resp.json()) 511 512 def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges: 513 """The challenges pinned to the head of a competition's list (#2070), 514 in the order they are shown.""" 515 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges") 516 _raise_for_status(resp) 517 return CompetitionPinnedChallenges.model_validate(resp.json()) 518 519 def set_pinned_challenges( 520 self, competition_id: str, challenge_ids: builtins.list[str] 521 ) -> CompetitionPinnedChallenges: 522 """Pin challenges to the head of a competition's list, in the order 523 given; the whole list is replaced (``[]`` clears it). Every id must be 524 one the competition serves now (``422 unknown_pinned_challenge``). A 525 pin is ordering and a marker, never visibility.""" 526 resp = self._http.request( 527 "PUT", 528 f"/admin/competitions/{competition_id}/pinned-challenges", 529 json={"challenge_ids": challenge_ids}, 530 ) 531 _raise_for_status(resp) 532 return CompetitionPinnedChallenges.model_validate(resp.json()) 533 534 def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges: 535 """An invitation-only suite's free sample challenges (#2306), in the 536 order its page shows them.""" 537 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges") 538 _raise_for_status(resp) 539 return CompetitionPreviewChallenges.model_validate(resp.json()) 540 541 def set_preview_challenges( 542 self, competition_id: str, challenge_ids: builtins.list[str] 543 ) -> CompetitionPreviewChallenges: 544 """Name an invitation-only suite's free sample challenges, replacing 545 the list (``[]`` offers none). Every id must be in the suite's set 546 (``422 unknown_preview_challenge``). Samples are open to everyone and 547 rank on no board of the suite.""" 548 resp = self._http.request( 549 "PUT", 550 f"/admin/competitions/{competition_id}/preview-challenges", 551 json={"challenge_ids": challenge_ids}, 552 ) 553 _raise_for_status(resp) 554 return CompetitionPreviewChallenges.model_validate(resp.json()) 555 556 def clone( 557 self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None 558 ) -> CompetitionInfo: 559 """Clone a competition as its **test** (#2041) and return the test. 560 561 Every rule is copied, rules files included (their bytes); only the 562 allow-list differs — ``private_hidden``, the " · 测试" title suffix, 563 ``test_of`` naming the source, and every window cleared or, with 564 ``starts_at``, moved by one offset so the sessions keep their shape. 565 No team, registration, solve, submission or instance is copied. 566 Refused (422) for a replay (``clone_of_virtual``), for a test 567 (``clone_of_test``), and for ``starts_at`` on a source with no start 568 (``clone_shift_needs_start``). 569 """ 570 resp = self._http.request( 571 "POST", 572 f"/admin/competitions/{competition_id}/clone", 573 json={ 574 "as_test": as_test, 575 "starts_at": starts_at.isoformat() if starts_at else None, 576 }, 577 ) 578 _raise_for_status(resp) 579 return CompetitionInfo.model_validate(resp.json()) 580 581 def unfreeze(self, competition_id: str) -> CompetitionInfo: 582 """公布最终结果: publish a frozen event's final standings (#2133). 583 584 Ends the hold on the players' boards, mails the entrants their final 585 result and nudges every open board. Idempotent. ``409 586 board_not_frozen`` when the event has no freeze; ``409 587 event_not_over`` before ``ends_at`` (to lift a freeze during play, 588 ``update(id, freeze_minutes=0)``). 589 """ 590 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze") 591 _raise_for_status(resp) 592 return CompetitionInfo.model_validate(resp.json()) 593 594 def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]: 595 """How a test differs from its official competition, field by field (#2041). 596 597 One entry for a test; one per test of it for an official competition; 598 none for a competition with no test. ``warnings`` counts the rules the 599 two do not share, ``intentional`` the allow-listed differences. 600 """ 601 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift") 602 _raise_for_status(resp) 603 return [CompetitionDriftInfo.model_validate(d) for d in resp.json()] 604 605 def sync_rules(self, competition_id: str) -> CompetitionDriftInfo: 606 """Apply the clone rules to a test competition again, in place (#2041), 607 and return the drift that is left.""" 608 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules") 609 _raise_for_status(resp) 610 return CompetitionDriftInfo.model_validate(resp.json()) 611 612 def challenge_changes( 613 self, competition_id: str 614 ) -> builtins.list[CompetitionChallengeChangeInfo]: 615 """Every challenge added to or taken out of a competition while it 616 locked its challenges in play (#2041), oldest first, with the reason.""" 617 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes") 618 _raise_for_status(resp) 619 return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()] 620 621 def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo: 622 """Add ``minutes`` to the session running right now (#2128). 623 624 Refused (409) when no session is running (``session_not_running``) 625 or when the new end would run into the next session 626 (``session_extend_overlap``, carrying ``next_session_starts_at``). 627 Every registered team is told over SSE and by mail, and the 628 teardown job reads the new end on its next tick. 629 """ 630 resp = self._http.request( 631 "POST", 632 f"/admin/competitions/{competition_id}/sessions/extend", 633 json={"minutes": minutes}, 634 ) 635 _raise_for_status(resp) 636 return CompetitionInfo.model_validate(resp.json()) 637 638 def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo: 639 """Close the session running right now, at this instant. 640 641 ``confirm=True`` is required: every environment is stopped within 642 one teardown tick and there is no way back. 643 """ 644 resp = self._http.request( 645 "POST", 646 f"/admin/competitions/{competition_id}/sessions/end", 647 json={"confirm": confirm}, 648 ) 649 _raise_for_status(resp) 650 return CompetitionInfo.model_validate(resp.json()) 651 652 def delete(self, competition_id: str) -> None: 653 """Hard-delete a competition. Cascades against per-comp teams, 654 memberships, invites, solve / submission rows.""" 655 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}") 656 _raise_for_status(resp) 657 658 def registrations( 659 self, 660 competition_id: str, 661 offset: int = 0, 662 limit: int = 50, 663 ) -> builtins.list[CompetitionRegistrationInfo]: 664 """Roster for ``competition_id``. Per the per-comp refactor, a 665 team's existence with the comp scope IS the registration.""" 666 resp = self._http.request( 667 "GET", 668 f"/admin/competitions/{competition_id}/registrations", 669 params={"offset": offset, "limit": limit}, 670 ) 671 _raise_for_status(resp) 672 return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]
54class AdminCompetitionsResource: 55 """Create / update / delete competitions and inspect their roster.""" 56 57 def __init__(self, http: BaseHttpClient) -> None: 58 self._http = http 59 60 def formats(self) -> builtins.list[CompetitionFormatPreset]: 61 """The wizard's named starting points for a new competition. 62 63 Each entry is a set of axis values, not a type: apply one to 64 ``create(...)`` and then override whatever the event needs. The 65 platform stores no record of which one was used. 66 """ 67 resp = self._http.request("GET", "/admin/competition-formats") 68 _raise_for_status(resp) 69 return [CompetitionFormatPreset.model_validate(f) for f in resp.json()] 70 71 def internet(self, competition_id: str) -> CompetitionInternetInfo: 72 """Every challenge here: the author's internet declaration and its verdict (#1969). 73 74 ``rows`` sorts the conflicts first — a challenge whose author says 75 it needs the internet and that this competition denies it — and 76 ``conflicts`` counts them. Change the policy or a per-challenge 77 override with :meth:`update` (``internet_policy`` / 78 ``challenge_internet``, the latter replaced whole). 79 """ 80 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet") 81 _raise_for_status(resp) 82 return CompetitionInternetInfo.model_validate(resp.json()) 83 84 def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied: 85 """Apply today's internet policy to the boxes already running (#2067). 86 87 The policy is read at start, so a change leaves running boxes as they 88 were (:meth:`internet`'s ``running_differs``). This moves each of them 89 to the verdict the policy gives today, through its node, and audits 90 each move with *reason*. Every box tried is in ``results`` with its 91 outcome; a failure keeps the box as it was. 92 """ 93 resp = self._http.request( 94 "POST", 95 f"/admin/competitions/{competition_id}/internet/apply", 96 json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"), 97 ) 98 _raise_for_status(resp) 99 return CompetitionInternetApplied.model_validate(resp.json()) 100 101 def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo: 102 """How players reach this competition's TCP challenges (#2082). 103 104 The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP 105 challenge's own way in, and how many live nodes can publish a port. 106 Change either with :meth:`update` (``tcp_access`` / 107 ``challenge_tcp_access``, the latter replaced whole). 108 """ 109 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access") 110 _raise_for_status(resp) 111 return CompetitionTcpAccessInfo.model_validate(resp.json()) 112 113 def pool(self, competition_id: str) -> CompetitionPoolInfo: 114 """Where this event's work runs: its dedicated node pool (#1965 E). 115 116 ``state`` is ``shared`` (no pool declared), ``ready`` or 117 ``no_live_node`` — the last meaning every start is refused and every 118 smoke waits, since nothing falls back to the shared pools. ``nodes`` 119 is every machine labelled ``competition=<id>``, live or not. Switch 120 the declaration with :meth:`update` (``dedicated_pool``). 121 """ 122 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool") 123 _raise_for_status(resp) 124 return CompetitionPoolInfo.model_validate(resp.json()) 125 126 def authoring_checklist(self, competition_id: str) -> AuthoringChecklistInfo: 127 """What must be right before this event opens automatic review (#1965 C3). 128 129 ``rows`` — the dedicated pool, the authoring windows, the contributor 130 agreement, the smoke quota, the internet policy — each ``ok`` or not, 131 with a ``reason`` code and the setting's ``value``; ``ready`` is all of 132 them. Advice: nothing refuses on it. Fix a row with :meth:`update`. 133 """ 134 resp = self._http.request( 135 "GET", f"/admin/competitions/{competition_id}/authoring-checklist" 136 ) 137 _raise_for_status(resp) 138 return AuthoringChecklistInfo.model_validate(resp.json()) 139 140 def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo: 141 """Every automatic smoke run charged in this competition, oldest first 142 (#1979 E3). 143 144 Each row says whose allowance it counted against (``purpose``: 145 ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's 146 day) and whether the platform refunded it (``refunded_at``, with the 147 reason) because the run reached no verdict. The whole set, unpaged. 148 """ 149 resp = self._http.request( 150 "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs" 151 ) 152 _raise_for_status(resp) 153 return AuthoringSmokeLedgerInfo.model_validate(resp.json()) 154 155 def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]: 156 """Every recorded change to this competition's scoring made while it 157 was live (#1965 H), oldest first — with who made it and the board 158 each one replaced. The public copy is 159 ``client.competitions.scoring_changes``.""" 160 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes") 161 _raise_for_status(resp) 162 return AdminScoringChangesResponse.model_validate(resp.json()).changes 163 164 def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo: 165 """The frozen scoring manifest (#2731 B): per frozen challenge, the 166 price inputs it was frozen with beside what the catalog says now, and 167 every re-price so far. ``frozen`` is ``False`` before the start.""" 168 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest") 169 _raise_for_status(resp) 170 return ScoringManifestInfo.model_validate(resp.json()) 171 172 def reprice_challenge( 173 self, competition_id: str, challenge_id: str, *, reason: str 174 ) -> ScoringManifestInfo: 175 """Re-price one frozen challenge from the catalog, with a recorded 176 reason (#2731 B) — the one door that moves a frozen price. Returns the 177 manifest as it now stands.""" 178 resp = self._http.request( 179 "POST", 180 f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice", 181 json=RepriceRequest(reason=reason).model_dump(mode="json"), 182 ) 183 _raise_for_status(resp) 184 return ScoringManifestInfo.model_validate(resp.json()) 185 186 def scoring_preview( 187 self, 188 scoring_rule: ScoringRuleName | Literal[""], 189 scoring_params: dict[str, Any], 190 challenges: builtins.list[ScoringPreviewChallenge], 191 *, 192 reference_id: str = "", 193 max_solvers: int = 100, 194 table_solvers: int = 10, 195 compare_curves: builtins.list[str] | None = None, 196 ) -> ScoringPreview: 197 """Price a **draft** scoring config with the board's own arithmetic. 198 199 What the scoring form's live curve calls: per-challenge values, the 200 value curve for ``reference_id`` (and any ``compare_curves``) and the 201 first-blood ladder. Nothing is stored, and a draft the board could 202 not run is refused as the write path refuses it. 203 """ 204 body = ScoringPreviewRequest( 205 scoring_rule=scoring_rule, 206 scoring_params=scoring_params, 207 challenges=challenges, 208 reference_id=reference_id, 209 max_solvers=max_solvers, 210 table_solvers=table_solvers, 211 compare_curves=compare_curves or [], 212 ) 213 resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump()) 214 _raise_for_status(resp) 215 return ScoringPreview.model_validate(resp.json()) 216 217 def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo: 218 """What this board's ``authoring_weight`` is worth, in challenges. 219 220 A ``peer_authoring`` weight is a fixed multiplier on a curve 221 output, so on its own it says nothing: 200 is generous on a board 222 of 100-point flags and a rounding error on one of 5000. This 223 prices it against the competition's own challenge set. 224 225 Read-only, and computed from the **stored** rule and challenge 226 set — a weight you have not saved is not what this answers about. 227 """ 228 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale") 229 _raise_for_status(resp) 230 return AuthoringScaleInfo.model_validate(resp.json()) 231 232 def records(self, competition_id: str) -> CompetitionRecords: 233 """Every record of one competition, for scoring outside the platform. 234 235 Teams (with each agent's owner and the owner's team), solves as the 236 board ranks them, submissions without their text, agent runs, and 237 uploads with their authors — complete, never a page. 238 """ 239 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records") 240 _raise_for_status(resp) 241 return CompetitionRecords.model_validate(resp.json()) 242 243 def list( 244 self, offset: int = 0, limit: int = 50, kind: str = "" 245 ) -> builtins.list[CompetitionInfo]: 246 """Every competition — past, current, scheduled. ``kind`` ∈ ``"" | 247 "event" | "suite"`` narrows it ("" = both).""" 248 params: dict[str, Any] = {"offset": offset, "limit": limit} 249 if kind: 250 params["kind"] = kind 251 resp = self._http.request("GET", "/admin/competitions", params=params) 252 _raise_for_status(resp) 253 return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]] 254 255 def create( 256 self, 257 *, 258 title: str | LocalizedText, 259 description: str | LocalizedText = "", 260 handbook: str | LocalizedText = "", 261 starts_at: datetime | None = None, 262 ends_at: datetime | None = None, 263 challenge_ids: builtins.list[str] | None = None, 264 challenge_scope: ChallengeScope = "set", 265 status: str = "draft", 266 access: str = "public", 267 kind: str = "event", 268 eval_standard: bool = False, 269 corpus_sha: str = "", 270 scoring_rule: str = "", 271 scoring_params: dict[str, Any] | None = None, 272 scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION, 273 sessions: builtins.list[tuple[datetime, datetime]] | None = None, 274 authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None, 275 awd_match: AwdMatchConfig | None = None, 276 entrants_are_agents: bool = False, 277 internet_policy: InternetPolicy = InternetPolicy.ALLOW, 278 challenge_internet: dict[str, InternetAccess] | None = None, 279 tcp_access: TcpAccess = TcpAccess.TLS_SNI, 280 challenge_tcp_access: dict[str, TcpAccess] | None = None, 281 session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP, 282 session_end_grace_s: int = 900, 283 solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE, 284 solve_grace_s: int = 900, 285 freeze_minutes: int = 0, 286 freeze_auto_unfreeze: bool = False, 287 ) -> CompetitionInfo: 288 """Create a new competition. ``title`` is a bare string — filed under 289 ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a 290 ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound 291 (sent as JSON ``null``). Unknown ``challenge_ids`` → 422. 292 ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the 293 default) — ``challenge_ids`` and nothing else, so an empty set starts 294 nothing — or ``"catalog"``, where any catalog challenge may start. 295 ``status`` defaults to ``draft`` (hidden from players); pass 296 ``"published"`` to make it immediately visible. ``access`` 297 defaults to ``public``; pass ``"private_listed"`` / 298 ``"private_hidden"`` for an invite-only competition. ``kind`` is 299 ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public 300 benchmark set open at any time: no window, no registration, listed on 301 the 测评集 page instead of the contest list; #2097). 302 ``eval_standard=True`` marks it for the platform's hosted evaluation 303 (only such a competition may back an official EvalCampaign); 304 ``corpus_sha`` pins the corpus snapshot it was certified against. 305 ``scoring_rule`` selects the board's ranking algorithm 306 (``""`` = flag count, ``static_points``, ``dynamic_points``) with 307 ``scoring_params`` carrying its tuning; set it here where you can, 308 because scoring freezes once the competition goes live. 309 ``scoring_rule_version`` is what a multi-flag challenge is worth 310 (#2731): ``challenge_total`` (the default) shares one total among 311 its scoring questions by weight; ``legacy`` pays every flag the 312 full value. It can be changed with :meth:`update` only before the 313 competition goes live (``409 scoring_rule_version_locked``). 314 ``sessions`` declares the playable slices of the window as 315 ``(start, end)`` pairs — a three-day event running 08:00-16:00 316 daily is three of them. Supplying it also *sets* ``starts_at`` / 317 ``ends_at``, which the server derives from the envelope, and 318 opts the competition into the play gate: outside a session, 319 submissions and instance starts are refused and running 320 instances are torn down. Omit it for a single continuous 321 window, which is how every competition behaves by default. 322 ``authoring_windows`` is the same idea for uploads: ``(start, 323 end)`` pairs, either side ``None`` for unbounded, outside which an 324 authored-challenge upload is refused — four mornings is four 325 pairs. Omit it to take uploads for as long as the event runs. 326 ``handbook`` is the organiser's long-form rules page, markdown, 327 capped server-side — ``description`` stays the one-line blurb the 328 listing and the page header truncate. Both are localised: pass a 329 ``{locale: text}`` map to write one translation, or a bare string, 330 which the server reads as ``en``. ⚠️ **A bare string is kept in the 331 signature rather than deprecated** — every caller that predates 332 localisation sends one, and a 422 for all of them would be a 333 breaking change dressed up as a feature; the coercion is the 334 server's (``coerce_localized``), so the two cannot disagree. 335 ``entrants_are_agents=True`` marks it an agent exhibition: human 336 registration is refused server-side and the browser shows a 337 spectate affordance rather than a sign-up button. 338 ``internet_policy`` decides whether instances started here may 339 reach the internet — ``allow`` (the default, today's behaviour), 340 ``deny``, or ``per_challenge`` (each challenge as its author 341 declared) — and ``challenge_internet`` overrides it per challenge 342 id (#1969). ``tcp_access`` is how players reach a TCP challenge — 343 ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and 344 ``challenge_tcp_access`` overrides it per challenge id (#2082). 345 ``session_end_mode`` decides what a play session's end does 346 to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace`` 347 (``session_end_grace_s``) / ``delete`` -- and the last session always 348 deletes (#2128). ``solve_env_mode`` decides what a full solve does to 349 the solved box -- ``destroy`` / ``grace`` (default, 350 ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the 351 players' scoreboard at ``ends_at - freeze_minutes`` until 352 :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event 353 ends); ``0`` never freezes (#2133).""" 354 resp = self._http.request( 355 "POST", 356 "/admin/competitions", 357 json={ 358 **({"title_i18n": title} if isinstance(title, dict) else {"title": title}), 359 "description": description, 360 "handbook": handbook, 361 "starts_at": starts_at.isoformat() if starts_at else None, 362 "ends_at": ends_at.isoformat() if ends_at else None, 363 "challenge_ids": challenge_ids or [], 364 "challenge_scope": challenge_scope, 365 "status": status, 366 "access": access, 367 "kind": kind, 368 "eval_standard": eval_standard, 369 "corpus_sha": corpus_sha, 370 "scoring_rule": scoring_rule, 371 "scoring_params": scoring_params or {}, 372 "scoring_rule_version": scoring_rule_version, 373 # An agent exhibition: entrants are dispatched agents, so 374 # human registration is refused and the browser offers a 375 # spectate affordance. See ``CompetitionCreate``. 376 "entrants_are_agents": entrants_are_agents, 377 # #1969: the internet axis and its per-challenge overrides. 378 "internet_policy": str(internet_policy), 379 "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()}, 380 # #2082: the way in for TCP challenges, and its overrides. 381 "tcp_access": str(tcp_access), 382 "challenge_tcp_access": { 383 k: str(v) for k, v in (challenge_tcp_access or {}).items() 384 }, 385 "session_end_mode": str(session_end_mode), 386 "session_end_grace_s": session_end_grace_s, 387 "solve_env_mode": str(solve_env_mode), 388 "solve_grace_s": solve_grace_s, 389 "freeze_minutes": freeze_minutes, 390 "freeze_auto_unfreeze": freeze_auto_unfreeze, 391 "sessions": [ 392 {"starts_at": s.isoformat(), "ends_at": e.isoformat()} 393 for s, e in (sessions or []) 394 ], 395 "authoring_windows": [ 396 { 397 "starts_at": s.isoformat() if s else None, 398 "ends_at": e.isoformat() if e else None, 399 } 400 for s, e in (authoring_windows or []) 401 ], 402 # ⚠️ **Absent, never `{}`.** The server's model is 403 # `extra="forbid"` with two required cadence fields, so 404 # an empty object is a 422 on every competition that 405 # runs no rounds. `None` here means "not configured in 406 # this request", which the upsert route can still 407 # satisfy tomorrow — never "this competition has no 408 # match". 409 **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}), 410 }, 411 ) 412 _raise_for_status(resp) 413 return CompetitionInfo.model_validate(resp.json()) 414 415 def update(self, competition_id: str, **fields: Any) -> CompetitionInfo: 416 """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}`` 417 title map, replaced; #2230) / ``title`` (a bare string, merged into the 418 locale it is written in) / ``description`` / ``starts_at`` / 419 ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` | 420 ``catalog``, #2553 — a change to what may be played, so a locked event 421 takes it only like a change to the set) / ``status`` / ``access`` / 422 ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` / 423 ``scoring_params`` / ``scoring_rule_version`` (before the start only) / 424 ``sessions`` / ``anonymous_entry`` / 425 ``anonymous_entry_kind`` / ``authoring_review`` (``human`` | 426 ``automatic``: under ``automatic`` a passing smoke on the event's 427 dedicated pool approves, publishes and curates an upload) / 428 ``authoring_smoke_quota`` (smoke runs per team per authoring window 429 under ``automatic``) / ``authoring_rate_limit_count`` + 430 ``authoring_rate_limit_window_s`` (uploads one team may make per 431 rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window`` 432 (new uploads one team may start per authoring window, a re-upload of its 433 own upload never counted; ``0`` is **unlimited**) / 434 ``pinned`` (pin / unpin the event for everybody, #2070 — **platform 435 admins only**, ``403 pin_requires_platform_admin`` for an organiser) / 436 ``organiser_contact_email`` (where entrants reach the organisers, shown 437 on the competition page and in the registration mail, #2283; ``""`` 438 removes it) / 439 ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the 440 platform's own authoring setting names, e.g. 441 ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move, 442 ``0`` defers to the platform; the runtime budget, build timeout, 443 auto-smoke budget and re-check interval are **platform admins only**, 444 ``403 authoring_override_requires_platform_admin``) / 445 ``authoring_cooldown`` (``default`` | 446 ``platform`` | ``competition`` | ``off``: the pause after repeated 447 rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` / 448 ``_pause_s`` read under ``competition``) / ``report_disclosure`` 449 (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what 450 players are shown about how they are doing, #2030) / ``freeze_minutes`` + 451 ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N 452 minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list 453 is what the wire accepts today, so it is a pointer to 454 ``CompetitionUpdate`` rather than a gate, and :meth:`create` is 455 the narrow one (it builds its body by hand and cannot carry a 456 field it does not name). ⚠️ ``anonymous_entry`` may be *widened* 457 freely and is refused on a narrowing once entrants have relied 458 on it, ``required`` → ``optional`` included: that transition 459 un-anonymises the whole field at once. Omit a field to leave it; pass 460 ``None`` to clear. ``sessions`` is the one field where ``[]`` is 461 meaningful rather than empty: it clears the schedule and returns 462 the competition to a single continuous window; ``authoring_windows`` 463 is the other, and ``[]`` there takes uploads for as long as the 464 event runs. Changing the scoring fields on a live competition needs 465 ``scoring_change_reason`` (``422 scoring_change_needs_reason`` 466 without one): the change is recorded and shown on the board — see 467 ``competitions.scoring_live``. ``lock_challenge_versions`` holds an 468 event's uploaded challenges still while it is in play (#2041); a 469 change to ``challenge_ids`` of such an event then needs 470 ``challenge_change_confirmed=True`` and a ``challenge_change_reason`` 471 (``409 challenge_set_locked`` otherwise), recorded per challenge — 472 see :meth:`challenge_changes`.""" 473 resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields) 474 _raise_for_status(resp) 475 return CompetitionInfo.model_validate(resp.json()) 476 477 def challenge_source(self, competition_id: str) -> CompetitionChallengeSource: 478 """Where a competition's challenge set comes from (#2234): the 479 competition it follows, whether that source passes its set on now 480 (only while it is published), and the competitions that follow it.""" 481 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source") 482 _raise_for_status(resp) 483 return CompetitionChallengeSource.model_validate(resp.json()) 484 485 def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo: 486 """Make a competition's challenge set follow ``source_id``'s, or stop 487 following with ``""`` (#2234). 488 489 The follower then serves the source's set, read live, minus player 490 uploads; its own list is cleared and not editable while it follows 491 (``409 challenge_set_followed``). Stopping keeps the set it serves at 492 that moment as its own list. One level only: refused with 493 ``challenge_source_chained`` / ``challenge_source_followed``; the 494 caller must organise the source (``403 challenge_source_forbidden``). 495 Solves, instances and boards stay each competition's own.""" 496 return self.update(competition_id, challenge_source_competition_id=source_id) 497 498 def reorder_challenges( 499 self, competition_id: str, challenge_ids: builtins.list[str] 500 ) -> CompetitionChallengeOrder: 501 """Put a competition's challenges in the order its entrants see them 502 (#2037). ``challenge_ids`` must be exactly the set it holds now, 503 reordered — ``competition_challenge_order_stale`` when another 504 organiser changed it meanwhile.""" 505 resp = self._http.request( 506 "PUT", 507 f"/admin/competitions/{competition_id}/challenge-order", 508 json={"challenge_ids": challenge_ids}, 509 ) 510 _raise_for_status(resp) 511 return CompetitionChallengeOrder.model_validate(resp.json()) 512 513 def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges: 514 """The challenges pinned to the head of a competition's list (#2070), 515 in the order they are shown.""" 516 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges") 517 _raise_for_status(resp) 518 return CompetitionPinnedChallenges.model_validate(resp.json()) 519 520 def set_pinned_challenges( 521 self, competition_id: str, challenge_ids: builtins.list[str] 522 ) -> CompetitionPinnedChallenges: 523 """Pin challenges to the head of a competition's list, in the order 524 given; the whole list is replaced (``[]`` clears it). Every id must be 525 one the competition serves now (``422 unknown_pinned_challenge``). A 526 pin is ordering and a marker, never visibility.""" 527 resp = self._http.request( 528 "PUT", 529 f"/admin/competitions/{competition_id}/pinned-challenges", 530 json={"challenge_ids": challenge_ids}, 531 ) 532 _raise_for_status(resp) 533 return CompetitionPinnedChallenges.model_validate(resp.json()) 534 535 def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges: 536 """An invitation-only suite's free sample challenges (#2306), in the 537 order its page shows them.""" 538 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges") 539 _raise_for_status(resp) 540 return CompetitionPreviewChallenges.model_validate(resp.json()) 541 542 def set_preview_challenges( 543 self, competition_id: str, challenge_ids: builtins.list[str] 544 ) -> CompetitionPreviewChallenges: 545 """Name an invitation-only suite's free sample challenges, replacing 546 the list (``[]`` offers none). Every id must be in the suite's set 547 (``422 unknown_preview_challenge``). Samples are open to everyone and 548 rank on no board of the suite.""" 549 resp = self._http.request( 550 "PUT", 551 f"/admin/competitions/{competition_id}/preview-challenges", 552 json={"challenge_ids": challenge_ids}, 553 ) 554 _raise_for_status(resp) 555 return CompetitionPreviewChallenges.model_validate(resp.json()) 556 557 def clone( 558 self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None 559 ) -> CompetitionInfo: 560 """Clone a competition as its **test** (#2041) and return the test. 561 562 Every rule is copied, rules files included (their bytes); only the 563 allow-list differs — ``private_hidden``, the " · 测试" title suffix, 564 ``test_of`` naming the source, and every window cleared or, with 565 ``starts_at``, moved by one offset so the sessions keep their shape. 566 No team, registration, solve, submission or instance is copied. 567 Refused (422) for a replay (``clone_of_virtual``), for a test 568 (``clone_of_test``), and for ``starts_at`` on a source with no start 569 (``clone_shift_needs_start``). 570 """ 571 resp = self._http.request( 572 "POST", 573 f"/admin/competitions/{competition_id}/clone", 574 json={ 575 "as_test": as_test, 576 "starts_at": starts_at.isoformat() if starts_at else None, 577 }, 578 ) 579 _raise_for_status(resp) 580 return CompetitionInfo.model_validate(resp.json()) 581 582 def unfreeze(self, competition_id: str) -> CompetitionInfo: 583 """公布最终结果: publish a frozen event's final standings (#2133). 584 585 Ends the hold on the players' boards, mails the entrants their final 586 result and nudges every open board. Idempotent. ``409 587 board_not_frozen`` when the event has no freeze; ``409 588 event_not_over`` before ``ends_at`` (to lift a freeze during play, 589 ``update(id, freeze_minutes=0)``). 590 """ 591 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze") 592 _raise_for_status(resp) 593 return CompetitionInfo.model_validate(resp.json()) 594 595 def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]: 596 """How a test differs from its official competition, field by field (#2041). 597 598 One entry for a test; one per test of it for an official competition; 599 none for a competition with no test. ``warnings`` counts the rules the 600 two do not share, ``intentional`` the allow-listed differences. 601 """ 602 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift") 603 _raise_for_status(resp) 604 return [CompetitionDriftInfo.model_validate(d) for d in resp.json()] 605 606 def sync_rules(self, competition_id: str) -> CompetitionDriftInfo: 607 """Apply the clone rules to a test competition again, in place (#2041), 608 and return the drift that is left.""" 609 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules") 610 _raise_for_status(resp) 611 return CompetitionDriftInfo.model_validate(resp.json()) 612 613 def challenge_changes( 614 self, competition_id: str 615 ) -> builtins.list[CompetitionChallengeChangeInfo]: 616 """Every challenge added to or taken out of a competition while it 617 locked its challenges in play (#2041), oldest first, with the reason.""" 618 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes") 619 _raise_for_status(resp) 620 return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()] 621 622 def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo: 623 """Add ``minutes`` to the session running right now (#2128). 624 625 Refused (409) when no session is running (``session_not_running``) 626 or when the new end would run into the next session 627 (``session_extend_overlap``, carrying ``next_session_starts_at``). 628 Every registered team is told over SSE and by mail, and the 629 teardown job reads the new end on its next tick. 630 """ 631 resp = self._http.request( 632 "POST", 633 f"/admin/competitions/{competition_id}/sessions/extend", 634 json={"minutes": minutes}, 635 ) 636 _raise_for_status(resp) 637 return CompetitionInfo.model_validate(resp.json()) 638 639 def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo: 640 """Close the session running right now, at this instant. 641 642 ``confirm=True`` is required: every environment is stopped within 643 one teardown tick and there is no way back. 644 """ 645 resp = self._http.request( 646 "POST", 647 f"/admin/competitions/{competition_id}/sessions/end", 648 json={"confirm": confirm}, 649 ) 650 _raise_for_status(resp) 651 return CompetitionInfo.model_validate(resp.json()) 652 653 def delete(self, competition_id: str) -> None: 654 """Hard-delete a competition. Cascades against per-comp teams, 655 memberships, invites, solve / submission rows.""" 656 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}") 657 _raise_for_status(resp) 658 659 def registrations( 660 self, 661 competition_id: str, 662 offset: int = 0, 663 limit: int = 50, 664 ) -> builtins.list[CompetitionRegistrationInfo]: 665 """Roster for ``competition_id``. Per the per-comp refactor, a 666 team's existence with the comp scope IS the registration.""" 667 resp = self._http.request( 668 "GET", 669 f"/admin/competitions/{competition_id}/registrations", 670 params={"offset": offset, "limit": limit}, 671 ) 672 _raise_for_status(resp) 673 return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]
Create / update / delete competitions and inspect their roster.
60 def formats(self) -> builtins.list[CompetitionFormatPreset]: 61 """The wizard's named starting points for a new competition. 62 63 Each entry is a set of axis values, not a type: apply one to 64 ``create(...)`` and then override whatever the event needs. The 65 platform stores no record of which one was used. 66 """ 67 resp = self._http.request("GET", "/admin/competition-formats") 68 _raise_for_status(resp) 69 return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]
The wizard's named starting points for a new competition.
Each entry is a set of axis values, not a type: apply one to
create(...) and then override whatever the event needs. The
platform stores no record of which one was used.
71 def internet(self, competition_id: str) -> CompetitionInternetInfo: 72 """Every challenge here: the author's internet declaration and its verdict (#1969). 73 74 ``rows`` sorts the conflicts first — a challenge whose author says 75 it needs the internet and that this competition denies it — and 76 ``conflicts`` counts them. Change the policy or a per-challenge 77 override with :meth:`update` (``internet_policy`` / 78 ``challenge_internet``, the latter replaced whole). 79 """ 80 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/internet") 81 _raise_for_status(resp) 82 return CompetitionInternetInfo.model_validate(resp.json())
Every challenge here: the author's internet declaration and its verdict (#1969).
rows sorts the conflicts first — a challenge whose author says
it needs the internet and that this competition denies it — and
conflicts counts them. Change the policy or a per-challenge
override with update() (internet_policy /
challenge_internet, the latter replaced whole).
84 def apply_internet(self, competition_id: str, *, reason: str) -> CompetitionInternetApplied: 85 """Apply today's internet policy to the boxes already running (#2067). 86 87 The policy is read at start, so a change leaves running boxes as they 88 were (:meth:`internet`'s ``running_differs``). This moves each of them 89 to the verdict the policy gives today, through its node, and audits 90 each move with *reason*. Every box tried is in ``results`` with its 91 outcome; a failure keeps the box as it was. 92 """ 93 resp = self._http.request( 94 "POST", 95 f"/admin/competitions/{competition_id}/internet/apply", 96 json=CompetitionInternetApplyRequest(reason=reason).model_dump(mode="json"), 97 ) 98 _raise_for_status(resp) 99 return CompetitionInternetApplied.model_validate(resp.json())
Apply today's internet policy to the boxes already running (#2067).
The policy is read at start, so a change leaves running boxes as they
were (internet()'s running_differs). This moves each of them
to the verdict the policy gives today, through its node, and audits
each move with reason. Every box tried is in results with its
outcome; a failure keeps the box as it was.
101 def tcp_access(self, competition_id: str) -> CompetitionTcpAccessInfo: 102 """How players reach this competition's TCP challenges (#2082). 103 104 The default (``tls_sni`` / ``public_port`` / ``vpn``), every TCP 105 challenge's own way in, and how many live nodes can publish a port. 106 Change either with :meth:`update` (``tcp_access`` / 107 ``challenge_tcp_access``, the latter replaced whole). 108 """ 109 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/tcp-access") 110 _raise_for_status(resp) 111 return CompetitionTcpAccessInfo.model_validate(resp.json())
How players reach this competition's TCP challenges (#2082).
The default (tls_sni / public_port / vpn), every TCP
challenge's own way in, and how many live nodes can publish a port.
Change either with update() (tcp_access /
challenge_tcp_access, the latter replaced whole).
113 def pool(self, competition_id: str) -> CompetitionPoolInfo: 114 """Where this event's work runs: its dedicated node pool (#1965 E). 115 116 ``state`` is ``shared`` (no pool declared), ``ready`` or 117 ``no_live_node`` — the last meaning every start is refused and every 118 smoke waits, since nothing falls back to the shared pools. ``nodes`` 119 is every machine labelled ``competition=<id>``, live or not. Switch 120 the declaration with :meth:`update` (``dedicated_pool``). 121 """ 122 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pool") 123 _raise_for_status(resp) 124 return CompetitionPoolInfo.model_validate(resp.json())
Where this event's work runs: its dedicated node pool (#1965 E).
state is shared (no pool declared), ready or
no_live_node — the last meaning every start is refused and every
smoke waits, since nothing falls back to the shared pools. nodes
is every machine labelled competition=<id>, live or not. Switch
the declaration with update() (dedicated_pool).
140 def smoke_ledger(self, competition_id: str) -> AuthoringSmokeLedgerInfo: 141 """Every automatic smoke run charged in this competition, oldest first 142 (#1979 E3). 143 144 Each row says whose allowance it counted against (``purpose``: 145 ``quota`` a team's window, ``retry`` nobody's, ``budget`` a person's 146 day) and whether the platform refunded it (``refunded_at``, with the 147 reason) because the run reached no verdict. The whole set, unpaged. 148 """ 149 resp = self._http.request( 150 "GET", f"/admin/competitions/{competition_id}/authoring/smoke-runs" 151 ) 152 _raise_for_status(resp) 153 return AuthoringSmokeLedgerInfo.model_validate(resp.json())
Every automatic smoke run charged in this competition, oldest first (#1979 E3).
Each row says whose allowance it counted against (purpose:
quota a team's window, retry nobody's, budget a person's
day) and whether the platform refunded it (refunded_at, with the
reason) because the run reached no verdict. The whole set, unpaged.
155 def scoring_changes(self, competition_id: str) -> builtins.list[AdminScoringChangeInfo]: 156 """Every recorded change to this competition's scoring made while it 157 was live (#1965 H), oldest first — with who made it and the board 158 each one replaced. The public copy is 159 ``client.competitions.scoring_changes``.""" 160 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-changes") 161 _raise_for_status(resp) 162 return AdminScoringChangesResponse.model_validate(resp.json()).changes
Every recorded change to this competition's scoring made while it
was live (#1965 H), oldest first — with who made it and the board
each one replaced. The public copy is
client.competitions.scoring_changes.
164 def scoring_manifest(self, competition_id: str) -> ScoringManifestInfo: 165 """The frozen scoring manifest (#2731 B): per frozen challenge, the 166 price inputs it was frozen with beside what the catalog says now, and 167 every re-price so far. ``frozen`` is ``False`` before the start.""" 168 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/scoring-manifest") 169 _raise_for_status(resp) 170 return ScoringManifestInfo.model_validate(resp.json())
The frozen scoring manifest (#2731 B): per frozen challenge, the
price inputs it was frozen with beside what the catalog says now, and
every re-price so far. frozen is False before the start.
172 def reprice_challenge( 173 self, competition_id: str, challenge_id: str, *, reason: str 174 ) -> ScoringManifestInfo: 175 """Re-price one frozen challenge from the catalog, with a recorded 176 reason (#2731 B) — the one door that moves a frozen price. Returns the 177 manifest as it now stands.""" 178 resp = self._http.request( 179 "POST", 180 f"/admin/competitions/{competition_id}/scoring-manifest/{challenge_id}/reprice", 181 json=RepriceRequest(reason=reason).model_dump(mode="json"), 182 ) 183 _raise_for_status(resp) 184 return ScoringManifestInfo.model_validate(resp.json())
Re-price one frozen challenge from the catalog, with a recorded reason (#2731 B) — the one door that moves a frozen price. Returns the manifest as it now stands.
186 def scoring_preview( 187 self, 188 scoring_rule: ScoringRuleName | Literal[""], 189 scoring_params: dict[str, Any], 190 challenges: builtins.list[ScoringPreviewChallenge], 191 *, 192 reference_id: str = "", 193 max_solvers: int = 100, 194 table_solvers: int = 10, 195 compare_curves: builtins.list[str] | None = None, 196 ) -> ScoringPreview: 197 """Price a **draft** scoring config with the board's own arithmetic. 198 199 What the scoring form's live curve calls: per-challenge values, the 200 value curve for ``reference_id`` (and any ``compare_curves``) and the 201 first-blood ladder. Nothing is stored, and a draft the board could 202 not run is refused as the write path refuses it. 203 """ 204 body = ScoringPreviewRequest( 205 scoring_rule=scoring_rule, 206 scoring_params=scoring_params, 207 challenges=challenges, 208 reference_id=reference_id, 209 max_solvers=max_solvers, 210 table_solvers=table_solvers, 211 compare_curves=compare_curves or [], 212 ) 213 resp = self._http.request("POST", "/admin/scoring/preview", json=body.model_dump()) 214 _raise_for_status(resp) 215 return ScoringPreview.model_validate(resp.json())
Price a draft scoring config with the board's own arithmetic.
What the scoring form's live curve calls: per-challenge values, the
value curve for reference_id (and any compare_curves) and the
first-blood ladder. Nothing is stored, and a draft the board could
not run is refused as the write path refuses it.
232 def records(self, competition_id: str) -> CompetitionRecords: 233 """Every record of one competition, for scoring outside the platform. 234 235 Teams (with each agent's owner and the owner's team), solves as the 236 board ranks them, submissions without their text, agent runs, and 237 uploads with their authors — complete, never a page. 238 """ 239 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records") 240 _raise_for_status(resp) 241 return CompetitionRecords.model_validate(resp.json())
Every record of one competition, for scoring outside the platform.
Teams (with each agent's owner and the owner's team), solves as the board ranks them, submissions without their text, agent runs, and uploads with their authors — complete, never a page.
243 def list( 244 self, offset: int = 0, limit: int = 50, kind: str = "" 245 ) -> builtins.list[CompetitionInfo]: 246 """Every competition — past, current, scheduled. ``kind`` ∈ ``"" | 247 "event" | "suite"`` narrows it ("" = both).""" 248 params: dict[str, Any] = {"offset": offset, "limit": limit} 249 if kind: 250 params["kind"] = kind 251 resp = self._http.request("GET", "/admin/competitions", params=params) 252 _raise_for_status(resp) 253 return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]
Every competition — past, current, scheduled. kind ∈ "" |
"event" | "suite" narrows it ("" = both).
255 def create( 256 self, 257 *, 258 title: str | LocalizedText, 259 description: str | LocalizedText = "", 260 handbook: str | LocalizedText = "", 261 starts_at: datetime | None = None, 262 ends_at: datetime | None = None, 263 challenge_ids: builtins.list[str] | None = None, 264 challenge_scope: ChallengeScope = "set", 265 status: str = "draft", 266 access: str = "public", 267 kind: str = "event", 268 eval_standard: bool = False, 269 corpus_sha: str = "", 270 scoring_rule: str = "", 271 scoring_params: dict[str, Any] | None = None, 272 scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION, 273 sessions: builtins.list[tuple[datetime, datetime]] | None = None, 274 authoring_windows: builtins.list[tuple[datetime | None, datetime | None]] | None = None, 275 awd_match: AwdMatchConfig | None = None, 276 entrants_are_agents: bool = False, 277 internet_policy: InternetPolicy = InternetPolicy.ALLOW, 278 challenge_internet: dict[str, InternetAccess] | None = None, 279 tcp_access: TcpAccess = TcpAccess.TLS_SNI, 280 challenge_tcp_access: dict[str, TcpAccess] | None = None, 281 session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP, 282 session_end_grace_s: int = 900, 283 solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE, 284 solve_grace_s: int = 900, 285 freeze_minutes: int = 0, 286 freeze_auto_unfreeze: bool = False, 287 ) -> CompetitionInfo: 288 """Create a new competition. ``title`` is a bare string — filed under 289 ``zh-CN`` when it holds a CJK ideograph, else ``en`` — or a 290 ``{locale: text}`` map naming both (#2230). ``None`` time fields = no bound 291 (sent as JSON ``null``). Unknown ``challenge_ids`` → 422. 292 ``challenge_scope`` is what entrants may play (#2553): ``"set"`` (the 293 default) — ``challenge_ids`` and nothing else, so an empty set starts 294 nothing — or ``"catalog"``, where any catalog challenge may start. 295 ``status`` defaults to ``draft`` (hidden from players); pass 296 ``"published"`` to make it immediately visible. ``access`` 297 defaults to ``public``; pass ``"private_listed"`` / 298 ``"private_hidden"`` for an invite-only competition. ``kind`` is 299 ``"event"`` (a hosted contest, the default) or ``"suite"`` (a public 300 benchmark set open at any time: no window, no registration, listed on 301 the 测评集 page instead of the contest list; #2097). 302 ``eval_standard=True`` marks it for the platform's hosted evaluation 303 (only such a competition may back an official EvalCampaign); 304 ``corpus_sha`` pins the corpus snapshot it was certified against. 305 ``scoring_rule`` selects the board's ranking algorithm 306 (``""`` = flag count, ``static_points``, ``dynamic_points``) with 307 ``scoring_params`` carrying its tuning; set it here where you can, 308 because scoring freezes once the competition goes live. 309 ``scoring_rule_version`` is what a multi-flag challenge is worth 310 (#2731): ``challenge_total`` (the default) shares one total among 311 its scoring questions by weight; ``legacy`` pays every flag the 312 full value. It can be changed with :meth:`update` only before the 313 competition goes live (``409 scoring_rule_version_locked``). 314 ``sessions`` declares the playable slices of the window as 315 ``(start, end)`` pairs — a three-day event running 08:00-16:00 316 daily is three of them. Supplying it also *sets* ``starts_at`` / 317 ``ends_at``, which the server derives from the envelope, and 318 opts the competition into the play gate: outside a session, 319 submissions and instance starts are refused and running 320 instances are torn down. Omit it for a single continuous 321 window, which is how every competition behaves by default. 322 ``authoring_windows`` is the same idea for uploads: ``(start, 323 end)`` pairs, either side ``None`` for unbounded, outside which an 324 authored-challenge upload is refused — four mornings is four 325 pairs. Omit it to take uploads for as long as the event runs. 326 ``handbook`` is the organiser's long-form rules page, markdown, 327 capped server-side — ``description`` stays the one-line blurb the 328 listing and the page header truncate. Both are localised: pass a 329 ``{locale: text}`` map to write one translation, or a bare string, 330 which the server reads as ``en``. ⚠️ **A bare string is kept in the 331 signature rather than deprecated** — every caller that predates 332 localisation sends one, and a 422 for all of them would be a 333 breaking change dressed up as a feature; the coercion is the 334 server's (``coerce_localized``), so the two cannot disagree. 335 ``entrants_are_agents=True`` marks it an agent exhibition: human 336 registration is refused server-side and the browser shows a 337 spectate affordance rather than a sign-up button. 338 ``internet_policy`` decides whether instances started here may 339 reach the internet — ``allow`` (the default, today's behaviour), 340 ``deny``, or ``per_challenge`` (each challenge as its author 341 declared) — and ``challenge_internet`` overrides it per challenge 342 id (#1969). ``tcp_access`` is how players reach a TCP challenge — 343 ``tls_sni`` (the default), ``public_port`` or ``vpn`` — and 344 ``challenge_tcp_access`` overrides it per challenge id (#2082). 345 ``session_end_mode`` decides what a play session's end does 346 to running boxes -- ``pause`` / ``stop_keep`` (default) / ``grace`` 347 (``session_end_grace_s``) / ``delete`` -- and the last session always 348 deletes (#2128). ``solve_env_mode`` decides what a full solve does to 349 the solved box -- ``destroy`` / ``grace`` (default, 350 ``solve_grace_s``) / ``keep_until_ttl`` (#2105). ``freeze_minutes`` holds the 351 players' scoreboard at ``ends_at - freeze_minutes`` until 352 :meth:`unfreeze` (or, with ``freeze_auto_unfreeze``, until the event 353 ends); ``0`` never freezes (#2133).""" 354 resp = self._http.request( 355 "POST", 356 "/admin/competitions", 357 json={ 358 **({"title_i18n": title} if isinstance(title, dict) else {"title": title}), 359 "description": description, 360 "handbook": handbook, 361 "starts_at": starts_at.isoformat() if starts_at else None, 362 "ends_at": ends_at.isoformat() if ends_at else None, 363 "challenge_ids": challenge_ids or [], 364 "challenge_scope": challenge_scope, 365 "status": status, 366 "access": access, 367 "kind": kind, 368 "eval_standard": eval_standard, 369 "corpus_sha": corpus_sha, 370 "scoring_rule": scoring_rule, 371 "scoring_params": scoring_params or {}, 372 "scoring_rule_version": scoring_rule_version, 373 # An agent exhibition: entrants are dispatched agents, so 374 # human registration is refused and the browser offers a 375 # spectate affordance. See ``CompetitionCreate``. 376 "entrants_are_agents": entrants_are_agents, 377 # #1969: the internet axis and its per-challenge overrides. 378 "internet_policy": str(internet_policy), 379 "challenge_internet": {k: str(v) for k, v in (challenge_internet or {}).items()}, 380 # #2082: the way in for TCP challenges, and its overrides. 381 "tcp_access": str(tcp_access), 382 "challenge_tcp_access": { 383 k: str(v) for k, v in (challenge_tcp_access or {}).items() 384 }, 385 "session_end_mode": str(session_end_mode), 386 "session_end_grace_s": session_end_grace_s, 387 "solve_env_mode": str(solve_env_mode), 388 "solve_grace_s": solve_grace_s, 389 "freeze_minutes": freeze_minutes, 390 "freeze_auto_unfreeze": freeze_auto_unfreeze, 391 "sessions": [ 392 {"starts_at": s.isoformat(), "ends_at": e.isoformat()} 393 for s, e in (sessions or []) 394 ], 395 "authoring_windows": [ 396 { 397 "starts_at": s.isoformat() if s else None, 398 "ends_at": e.isoformat() if e else None, 399 } 400 for s, e in (authoring_windows or []) 401 ], 402 # ⚠️ **Absent, never `{}`.** The server's model is 403 # `extra="forbid"` with two required cadence fields, so 404 # an empty object is a 422 on every competition that 405 # runs no rounds. `None` here means "not configured in 406 # this request", which the upsert route can still 407 # satisfy tomorrow — never "this competition has no 408 # match". 409 **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}), 410 }, 411 ) 412 _raise_for_status(resp) 413 return CompetitionInfo.model_validate(resp.json())
Create a new competition. title is a bare string — filed under
zh-CN when it holds a CJK ideograph, else en — or a
{locale: text} map naming both (#2230). None time fields = no bound
(sent as JSON null). Unknown challenge_ids → 422.
challenge_scope is what entrants may play (#2553): "set" (the
default) — challenge_ids and nothing else, so an empty set starts
nothing — or "catalog", where any catalog challenge may start.
status defaults to draft (hidden from players); pass
"published" to make it immediately visible. access
defaults to public; pass "private_listed" /
"private_hidden" for an invite-only competition. kind is
"event" (a hosted contest, the default) or "suite" (a public
benchmark set open at any time: no window, no registration, listed on
the 测评集 page instead of the contest list; #2097).
eval_standard=True marks it for the platform's hosted evaluation
(only such a competition may back an official EvalCampaign);
corpus_sha pins the corpus snapshot it was certified against.
scoring_rule selects the board's ranking algorithm
("" = flag count, static_points, dynamic_points) with
scoring_params carrying its tuning; set it here where you can,
because scoring freezes once the competition goes live.
scoring_rule_version is what a multi-flag challenge is worth
(#2731): challenge_total (the default) shares one total among
its scoring questions by weight; legacy pays every flag the
full value. It can be changed with update() only before the
competition goes live (409 scoring_rule_version_locked).
sessions declares the playable slices of the window as
(start, end) pairs — a three-day event running 08:00-16:00
daily is three of them. Supplying it also sets starts_at /
ends_at, which the server derives from the envelope, and
opts the competition into the play gate: outside a session,
submissions and instance starts are refused and running
instances are torn down. Omit it for a single continuous
window, which is how every competition behaves by default.
authoring_windows is the same idea for uploads: (start,
end) pairs, either side None for unbounded, outside which an
authored-challenge upload is refused — four mornings is four
pairs. Omit it to take uploads for as long as the event runs.
handbook is the organiser's long-form rules page, markdown,
capped server-side — description stays the one-line blurb the
listing and the page header truncate. Both are localised: pass a
{locale: text} map to write one translation, or a bare string,
which the server reads as en. ⚠️ A bare string is kept in the
signature rather than deprecated — every caller that predates
localisation sends one, and a 422 for all of them would be a
breaking change dressed up as a feature; the coercion is the
server's (coerce_localized), so the two cannot disagree.
entrants_are_agents=True marks it an agent exhibition: human
registration is refused server-side and the browser shows a
spectate affordance rather than a sign-up button.
internet_policy decides whether instances started here may
reach the internet — allow (the default, today's behaviour),
deny, or per_challenge (each challenge as its author
declared) — and challenge_internet overrides it per challenge
id (#1969). tcp_access is how players reach a TCP challenge —
tls_sni (the default), public_port or vpn — and
challenge_tcp_access overrides it per challenge id (#2082).
session_end_mode decides what a play session's end does
to running boxes -- pause / stop_keep (default) / grace
(session_end_grace_s) / delete -- and the last session always
deletes (#2128). solve_env_mode decides what a full solve does to
the solved box -- destroy / grace (default,
solve_grace_s) / keep_until_ttl (#2105). freeze_minutes holds the
players' scoreboard at ends_at - freeze_minutes until
unfreeze() (or, with freeze_auto_unfreeze, until the event
ends); 0 never freezes (#2133).
415 def update(self, competition_id: str, **fields: Any) -> CompetitionInfo: 416 """PATCH any subset of ``title_i18n`` (the whole ``{locale: text}`` 417 title map, replaced; #2230) / ``title`` (a bare string, merged into the 418 locale it is written in) / ``description`` / ``starts_at`` / 419 ``ends_at`` / ``challenge_ids`` / ``challenge_scope`` (``set`` | 420 ``catalog``, #2553 — a change to what may be played, so a locked event 421 takes it only like a change to the set) / ``status`` / ``access`` / 422 ``kind`` / ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` / 423 ``scoring_params`` / ``scoring_rule_version`` (before the start only) / 424 ``sessions`` / ``anonymous_entry`` / 425 ``anonymous_entry_kind`` / ``authoring_review`` (``human`` | 426 ``automatic``: under ``automatic`` a passing smoke on the event's 427 dedicated pool approves, publishes and curates an upload) / 428 ``authoring_smoke_quota`` (smoke runs per team per authoring window 429 under ``automatic``) / ``authoring_rate_limit_count`` + 430 ``authoring_rate_limit_window_s`` (uploads one team may make per 431 rolling window, #2079; ``0`` is off) / ``authoring_uploads_per_window`` 432 (new uploads one team may start per authoring window, a re-upload of its 433 own upload never counted; ``0`` is **unlimited**) / 434 ``pinned`` (pin / unpin the event for everybody, #2070 — **platform 435 admins only**, ``403 pin_requires_platform_admin`` for an organiser) / 436 ``organiser_contact_email`` (where entrants reach the organisers, shown 437 on the competition page and in the registration mail, #2283; ``""`` 438 removes it) / 439 ``authoring_overrides`` (#2885: a ``{setting: int}`` object of the 440 platform's own authoring setting names, e.g. 441 ``{"authored_challenge_memory_mb": 8192}``; only the keys sent move, 442 ``0`` defers to the platform; the runtime budget, build timeout, 443 auto-smoke budget and re-check interval are **platform admins only**, 444 ``403 authoring_override_requires_platform_admin``) / 445 ``authoring_cooldown`` (``default`` | 446 ``platform`` | ``competition`` | ``off``: the pause after repeated 447 rejections, with ``authoring_cooldown_threshold`` / ``_window_s`` / 448 ``_pause_s`` read under ``competition``) / ``report_disclosure`` 449 (``default`` | ``after_lock`` | ``own`` | ``own_and_board``: what 450 players are shown about how they are doing, #2030) / ``freeze_minutes`` + 451 ``freeze_auto_unfreeze`` (hold the players' scoreboard for the last N 452 minutes, #2133; ``0`` is off). ⚠️ This is a **passthrough** — the list 453 is what the wire accepts today, so it is a pointer to 454 ``CompetitionUpdate`` rather than a gate, and :meth:`create` is 455 the narrow one (it builds its body by hand and cannot carry a 456 field it does not name). ⚠️ ``anonymous_entry`` may be *widened* 457 freely and is refused on a narrowing once entrants have relied 458 on it, ``required`` → ``optional`` included: that transition 459 un-anonymises the whole field at once. Omit a field to leave it; pass 460 ``None`` to clear. ``sessions`` is the one field where ``[]`` is 461 meaningful rather than empty: it clears the schedule and returns 462 the competition to a single continuous window; ``authoring_windows`` 463 is the other, and ``[]`` there takes uploads for as long as the 464 event runs. Changing the scoring fields on a live competition needs 465 ``scoring_change_reason`` (``422 scoring_change_needs_reason`` 466 without one): the change is recorded and shown on the board — see 467 ``competitions.scoring_live``. ``lock_challenge_versions`` holds an 468 event's uploaded challenges still while it is in play (#2041); a 469 change to ``challenge_ids`` of such an event then needs 470 ``challenge_change_confirmed=True`` and a ``challenge_change_reason`` 471 (``409 challenge_set_locked`` otherwise), recorded per challenge — 472 see :meth:`challenge_changes`.""" 473 resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields) 474 _raise_for_status(resp) 475 return CompetitionInfo.model_validate(resp.json())
PATCH any subset of title_i18n (the whole {locale: text}
title map, replaced; #2230) / title (a bare string, merged into the
locale it is written in) / description / starts_at /
ends_at / challenge_ids / challenge_scope (set |
catalog, #2553 — a change to what may be played, so a locked event
takes it only like a change to the set) / status / access /
kind / eval_standard / corpus_sha / scoring_rule /
scoring_params / scoring_rule_version (before the start only) /
sessions / anonymous_entry /
anonymous_entry_kind / authoring_review (human |
automatic: under automatic a passing smoke on the event's
dedicated pool approves, publishes and curates an upload) /
authoring_smoke_quota (smoke runs per team per authoring window
under automatic) / authoring_rate_limit_count +
authoring_rate_limit_window_s (uploads one team may make per
rolling window, #2079; 0 is off) / authoring_uploads_per_window
(new uploads one team may start per authoring window, a re-upload of its
own upload never counted; 0 is unlimited) /
pinned (pin / unpin the event for everybody, #2070 — platform
admins only, 403 pin_requires_platform_admin for an organiser) /
organiser_contact_email (where entrants reach the organisers, shown
on the competition page and in the registration mail, #2283; ""
removes it) /
authoring_overrides (#2885: a {setting: int} object of the
platform's own authoring setting names, e.g.
{"authored_challenge_memory_mb": 8192}; only the keys sent move,
0 defers to the platform; the runtime budget, build timeout,
auto-smoke budget and re-check interval are platform admins only,
403 authoring_override_requires_platform_admin) /
authoring_cooldown (default |
platform | competition | off: the pause after repeated
rejections, with authoring_cooldown_threshold / _window_s /
_pause_s read under competition) / report_disclosure
(default | after_lock | own | own_and_board: what
players are shown about how they are doing, #2030) / freeze_minutes +
freeze_auto_unfreeze (hold the players' scoreboard for the last N
minutes, #2133; 0 is off). ⚠️ This is a passthrough — the list
is what the wire accepts today, so it is a pointer to
CompetitionUpdate rather than a gate, and create() is
the narrow one (it builds its body by hand and cannot carry a
field it does not name). ⚠️ anonymous_entry may be widened
freely and is refused on a narrowing once entrants have relied
on it, required → optional included: that transition
un-anonymises the whole field at once. Omit a field to leave it; pass
None to clear. sessions is the one field where [] is
meaningful rather than empty: it clears the schedule and returns
the competition to a single continuous window; authoring_windows
is the other, and [] there takes uploads for as long as the
event runs. Changing the scoring fields on a live competition needs
scoring_change_reason (422 scoring_change_needs_reason
without one): the change is recorded and shown on the board — see
competitions.scoring_live. lock_challenge_versions holds an
event's uploaded challenges still while it is in play (#2041); a
change to challenge_ids of such an event then needs
challenge_change_confirmed=True and a challenge_change_reason
(409 challenge_set_locked otherwise), recorded per challenge —
see challenge_changes().
477 def challenge_source(self, competition_id: str) -> CompetitionChallengeSource: 478 """Where a competition's challenge set comes from (#2234): the 479 competition it follows, whether that source passes its set on now 480 (only while it is published), and the competitions that follow it.""" 481 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-source") 482 _raise_for_status(resp) 483 return CompetitionChallengeSource.model_validate(resp.json())
Where a competition's challenge set comes from (#2234): the competition it follows, whether that source passes its set on now (only while it is published), and the competitions that follow it.
485 def set_challenge_source(self, competition_id: str, source_id: str) -> CompetitionInfo: 486 """Make a competition's challenge set follow ``source_id``'s, or stop 487 following with ``""`` (#2234). 488 489 The follower then serves the source's set, read live, minus player 490 uploads; its own list is cleared and not editable while it follows 491 (``409 challenge_set_followed``). Stopping keeps the set it serves at 492 that moment as its own list. One level only: refused with 493 ``challenge_source_chained`` / ``challenge_source_followed``; the 494 caller must organise the source (``403 challenge_source_forbidden``). 495 Solves, instances and boards stay each competition's own.""" 496 return self.update(competition_id, challenge_source_competition_id=source_id)
Make a competition's challenge set follow source_id's, or stop
following with "" (#2234).
The follower then serves the source's set, read live, minus player
uploads; its own list is cleared and not editable while it follows
(409 challenge_set_followed). Stopping keeps the set it serves at
that moment as its own list. One level only: refused with
challenge_source_chained / challenge_source_followed; the
caller must organise the source (403 challenge_source_forbidden).
Solves, instances and boards stay each competition's own.
498 def reorder_challenges( 499 self, competition_id: str, challenge_ids: builtins.list[str] 500 ) -> CompetitionChallengeOrder: 501 """Put a competition's challenges in the order its entrants see them 502 (#2037). ``challenge_ids`` must be exactly the set it holds now, 503 reordered — ``competition_challenge_order_stale`` when another 504 organiser changed it meanwhile.""" 505 resp = self._http.request( 506 "PUT", 507 f"/admin/competitions/{competition_id}/challenge-order", 508 json={"challenge_ids": challenge_ids}, 509 ) 510 _raise_for_status(resp) 511 return CompetitionChallengeOrder.model_validate(resp.json())
Put a competition's challenges in the order its entrants see them
(#2037). challenge_ids must be exactly the set it holds now,
reordered — competition_challenge_order_stale when another
organiser changed it meanwhile.
513 def pinned_challenges(self, competition_id: str) -> CompetitionPinnedChallenges: 514 """The challenges pinned to the head of a competition's list (#2070), 515 in the order they are shown.""" 516 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/pinned-challenges") 517 _raise_for_status(resp) 518 return CompetitionPinnedChallenges.model_validate(resp.json())
The challenges pinned to the head of a competition's list (#2070), in the order they are shown.
520 def set_pinned_challenges( 521 self, competition_id: str, challenge_ids: builtins.list[str] 522 ) -> CompetitionPinnedChallenges: 523 """Pin challenges to the head of a competition's list, in the order 524 given; the whole list is replaced (``[]`` clears it). Every id must be 525 one the competition serves now (``422 unknown_pinned_challenge``). A 526 pin is ordering and a marker, never visibility.""" 527 resp = self._http.request( 528 "PUT", 529 f"/admin/competitions/{competition_id}/pinned-challenges", 530 json={"challenge_ids": challenge_ids}, 531 ) 532 _raise_for_status(resp) 533 return CompetitionPinnedChallenges.model_validate(resp.json())
Pin challenges to the head of a competition's list, in the order
given; the whole list is replaced ([] clears it). Every id must be
one the competition serves now (422 unknown_pinned_challenge). A
pin is ordering and a marker, never visibility.
535 def preview_challenges(self, competition_id: str) -> CompetitionPreviewChallenges: 536 """An invitation-only suite's free sample challenges (#2306), in the 537 order its page shows them.""" 538 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/preview-challenges") 539 _raise_for_status(resp) 540 return CompetitionPreviewChallenges.model_validate(resp.json())
An invitation-only suite's free sample challenges (#2306), in the order its page shows them.
542 def set_preview_challenges( 543 self, competition_id: str, challenge_ids: builtins.list[str] 544 ) -> CompetitionPreviewChallenges: 545 """Name an invitation-only suite's free sample challenges, replacing 546 the list (``[]`` offers none). Every id must be in the suite's set 547 (``422 unknown_preview_challenge``). Samples are open to everyone and 548 rank on no board of the suite.""" 549 resp = self._http.request( 550 "PUT", 551 f"/admin/competitions/{competition_id}/preview-challenges", 552 json={"challenge_ids": challenge_ids}, 553 ) 554 _raise_for_status(resp) 555 return CompetitionPreviewChallenges.model_validate(resp.json())
Name an invitation-only suite's free sample challenges, replacing
the list ([] offers none). Every id must be in the suite's set
(422 unknown_preview_challenge). Samples are open to everyone and
rank on no board of the suite.
557 def clone( 558 self, competition_id: str, *, as_test: bool = True, starts_at: datetime | None = None 559 ) -> CompetitionInfo: 560 """Clone a competition as its **test** (#2041) and return the test. 561 562 Every rule is copied, rules files included (their bytes); only the 563 allow-list differs — ``private_hidden``, the " · 测试" title suffix, 564 ``test_of`` naming the source, and every window cleared or, with 565 ``starts_at``, moved by one offset so the sessions keep their shape. 566 No team, registration, solve, submission or instance is copied. 567 Refused (422) for a replay (``clone_of_virtual``), for a test 568 (``clone_of_test``), and for ``starts_at`` on a source with no start 569 (``clone_shift_needs_start``). 570 """ 571 resp = self._http.request( 572 "POST", 573 f"/admin/competitions/{competition_id}/clone", 574 json={ 575 "as_test": as_test, 576 "starts_at": starts_at.isoformat() if starts_at else None, 577 }, 578 ) 579 _raise_for_status(resp) 580 return CompetitionInfo.model_validate(resp.json())
Clone a competition as its test (#2041) and return the test.
Every rule is copied, rules files included (their bytes); only the
allow-list differs — private_hidden, the " · 测试" title suffix,
test_of naming the source, and every window cleared or, with
starts_at, moved by one offset so the sessions keep their shape.
No team, registration, solve, submission or instance is copied.
Refused (422) for a replay (clone_of_virtual), for a test
(clone_of_test), and for starts_at on a source with no start
(clone_shift_needs_start).
582 def unfreeze(self, competition_id: str) -> CompetitionInfo: 583 """公布最终结果: publish a frozen event's final standings (#2133). 584 585 Ends the hold on the players' boards, mails the entrants their final 586 result and nudges every open board. Idempotent. ``409 587 board_not_frozen`` when the event has no freeze; ``409 588 event_not_over`` before ``ends_at`` (to lift a freeze during play, 589 ``update(id, freeze_minutes=0)``). 590 """ 591 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/unfreeze") 592 _raise_for_status(resp) 593 return CompetitionInfo.model_validate(resp.json())
公布最终结果: publish a frozen event's final standings (#2133).
Ends the hold on the players' boards, mails the entrants their final
result and nudges every open board. Idempotent. 409
board_not_frozen when the event has no freeze; 409
event_not_over before ends_at (to lift a freeze during play,
update(id, freeze_minutes=0)).
595 def drift(self, competition_id: str) -> builtins.list[CompetitionDriftInfo]: 596 """How a test differs from its official competition, field by field (#2041). 597 598 One entry for a test; one per test of it for an official competition; 599 none for a competition with no test. ``warnings`` counts the rules the 600 two do not share, ``intentional`` the allow-listed differences. 601 """ 602 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/drift") 603 _raise_for_status(resp) 604 return [CompetitionDriftInfo.model_validate(d) for d in resp.json()]
How a test differs from its official competition, field by field (#2041).
One entry for a test; one per test of it for an official competition;
none for a competition with no test. warnings counts the rules the
two do not share, intentional the allow-listed differences.
606 def sync_rules(self, competition_id: str) -> CompetitionDriftInfo: 607 """Apply the clone rules to a test competition again, in place (#2041), 608 and return the drift that is left.""" 609 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/sync-rules") 610 _raise_for_status(resp) 611 return CompetitionDriftInfo.model_validate(resp.json())
Apply the clone rules to a test competition again, in place (#2041), and return the drift that is left.
613 def challenge_changes( 614 self, competition_id: str 615 ) -> builtins.list[CompetitionChallengeChangeInfo]: 616 """Every challenge added to or taken out of a competition while it 617 locked its challenges in play (#2041), oldest first, with the reason.""" 618 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/challenge-changes") 619 _raise_for_status(resp) 620 return [CompetitionChallengeChangeInfo.model_validate(r) for r in resp.json()]
Every challenge added to or taken out of a competition while it locked its challenges in play (#2041), oldest first, with the reason.
622 def extend_session(self, competition_id: str, minutes: int) -> CompetitionInfo: 623 """Add ``minutes`` to the session running right now (#2128). 624 625 Refused (409) when no session is running (``session_not_running``) 626 or when the new end would run into the next session 627 (``session_extend_overlap``, carrying ``next_session_starts_at``). 628 Every registered team is told over SSE and by mail, and the 629 teardown job reads the new end on its next tick. 630 """ 631 resp = self._http.request( 632 "POST", 633 f"/admin/competitions/{competition_id}/sessions/extend", 634 json={"minutes": minutes}, 635 ) 636 _raise_for_status(resp) 637 return CompetitionInfo.model_validate(resp.json())
Add minutes to the session running right now (#2128).
Refused (409) when no session is running (session_not_running)
or when the new end would run into the next session
(session_extend_overlap, carrying next_session_starts_at).
Every registered team is told over SSE and by mail, and the
teardown job reads the new end on its next tick.
639 def end_session(self, competition_id: str, *, confirm: bool = False) -> CompetitionInfo: 640 """Close the session running right now, at this instant. 641 642 ``confirm=True`` is required: every environment is stopped within 643 one teardown tick and there is no way back. 644 """ 645 resp = self._http.request( 646 "POST", 647 f"/admin/competitions/{competition_id}/sessions/end", 648 json={"confirm": confirm}, 649 ) 650 _raise_for_status(resp) 651 return CompetitionInfo.model_validate(resp.json())
Close the session running right now, at this instant.
confirm=True is required: every environment is stopped within
one teardown tick and there is no way back.
653 def delete(self, competition_id: str) -> None: 654 """Hard-delete a competition. Cascades against per-comp teams, 655 memberships, invites, solve / submission rows.""" 656 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}") 657 _raise_for_status(resp)
Hard-delete a competition. Cascades against per-comp teams, memberships, invites, solve / submission rows.
659 def registrations( 660 self, 661 competition_id: str, 662 offset: int = 0, 663 limit: int = 50, 664 ) -> builtins.list[CompetitionRegistrationInfo]: 665 """Roster for ``competition_id``. Per the per-comp refactor, a 666 team's existence with the comp scope IS the registration.""" 667 resp = self._http.request( 668 "GET", 669 f"/admin/competitions/{competition_id}/registrations", 670 params={"offset": offset, "limit": limit}, 671 ) 672 _raise_for_status(resp) 673 return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]
Roster for competition_id. Per the per-comp refactor, a
team's existence with the comp scope IS the registration.