ctfy.sdk.admin_resources.registrations
client.admin.registrations — the organiser's roster, review and export.
Every call here is gated server-side on require_registration_reviewer
and, where sensitive fields are collected, lands an audit row naming the
caller. Treat the returned rows as personal data: they carry legal names,
phone numbers and postal addresses.
1"""``client.admin.registrations`` — the organiser's roster, review and export. 2 3Every call here is gated server-side on ``require_registration_reviewer`` 4and, where sensitive fields are collected, lands an audit row naming the 5caller. Treat the returned rows as personal data: they carry legal names, 6phone numbers and postal addresses. 7""" 8 9from __future__ import annotations 10 11import builtins 12from typing import Literal 13 14from ctfy.sdk._helpers import _raise_for_status 15from ctfy.sdk.base import BaseHttpClient 16from ctfy.server.models import ( 17 BulkApproveRequest, 18 BulkApproveResponse, 19 BulkApproveTeam, 20 EligibilityImpact, 21 RegistrationConditionsPreview, 22 RegistrationIdentityEvidence, 23 RegistrationMessageTemplate, 24 RegistrationMessageTemplates, 25 RegistrationMessageTemplateWrite, 26 RegistrationReviewNote, 27 RegistrationReviewNoteRequest, 28 RegistrationReviewNotes, 29 RegistrationSubmitRequest, 30 RegistrationSummary, 31 RegistrationTeamDetail, 32 RegistrationTeamRow, 33 RenderedReviewMessage, 34 ReviewPresence, 35 ReviewPresenceBeat, 36 ReviewTimeline, 37 ReviewTodoItemRequest, 38 WaitlistBackfillRequest, 39 WaitlistBackfillResult, 40) 41 42 43class AdminRegistrationsResource: 44 def __init__(self, http: BaseHttpClient) -> None: 45 self._http = http 46 47 def roster( 48 self, 49 competition_id: str, 50 *, 51 status: str = "", 52 eligibility: str = "", 53 q: str = "", 54 sort: str = "", 55 waitlist: bool = False, 56 offset: int = 0, 57 limit: int = 50, 58 ) -> builtins.list[RegistrationTeamRow]: 59 """One page of the roster with entrant details, grouped by team. 60 61 Paged and verdict-filtered server-side: each dossier costs an 62 AES-GCM open per sealed field, so a thousand-entrant competition 63 cannot afford "fetch everything and filter here" — which is 64 exactly what this used to do. 65 66 ``status`` is ``pending`` / ``approved`` / ``rejected``; empty 67 means every verdict. ``eligibility`` is ``verified`` / ``invited`` / 68 ``lapsed`` and keeps the squads with at least one member so marked 69 (#2184; empty = every squad). ``q`` searches team name, player id, 70 nickname, e-mail and real name (#2518); ``sort`` is ``created`` 71 (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order, 72 then the page, all over the whole competition. ``waitlist`` keeps 73 only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all` 74 to walk the whole roster. Each call is audited as a PII read. 75 """ 76 params: dict[str, object] = {"offset": offset, "limit": limit} 77 if status: 78 params["status"] = status 79 if eligibility: 80 params["eligibility"] = eligibility 81 if q: 82 params["q"] = q 83 if sort: 84 params["sort"] = sort 85 if waitlist: 86 params["waitlist"] = True 87 resp = self._http.request( 88 "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params 89 ) 90 _raise_for_status(resp) 91 return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]] 92 93 def roster_all( 94 self, competition_id: str, *, status: str = "", page_size: int = 200 95 ) -> builtins.list[RegistrationTeamRow]: 96 """Every page, concatenated — for scripts that genuinely want all. 97 98 Kept explicit rather than making :meth:`roster` unbounded, so 99 "walk the whole roster" is a decision a caller makes rather than 100 the default cost of asking for any of it. Note each page lands 101 its own audit row, which is correct: this reads every entrant's 102 personal data. 103 """ 104 out: builtins.list[RegistrationTeamRow] = [] 105 offset = 0 106 while True: 107 page = self.roster(competition_id, status=status, offset=offset, limit=page_size) 108 out.extend(page) 109 if len(page) < page_size: 110 return out 111 offset += page_size 112 113 def summary(self, competition_id: str) -> RegistrationSummary: 114 """Per-verdict totals for the whole competition. 115 116 What the roster's filter tabs are labelled with — counting a page 117 would report the page size. Carries no personal data, so unlike 118 :meth:`roster` it is not audited. 119 """ 120 resp = self._http.request( 121 "GET", f"/admin/competitions/{competition_id}/registration-summary" 122 ) 123 _raise_for_status(resp) 124 return RegistrationSummary.model_validate(resp.json()) 125 126 def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult: 127 """Move waitlisted teams onto the free places, in queue order (#2767). 128 129 ``limit`` caps how many move (``0`` = as many as are free). The 130 server counts and promotes in one statement, so two reviewers 131 backfilling at once never overfill. Promoted teams stay ``pending``. 132 """ 133 resp = self._http.request( 134 "POST", 135 f"/admin/competitions/{competition_id}/registration-waitlist/backfill", 136 json=WaitlistBackfillRequest(limit=limit).model_dump(), 137 ) 138 _raise_for_status(resp) 139 return WaitlistBackfillResult.model_validate(resp.json()) 140 141 def eligibility_impact( 142 self, competition_id: str, *, domains: builtins.list[str] | None = None 143 ) -> EligibilityImpact: 144 """What an institution rule means for the people already registered (#2184). 145 146 Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is 147 not a PII read. ``domains`` previews a set instead of the stored one. 148 """ 149 resp = self._http.request( 150 "GET", 151 f"/admin/competitions/{competition_id}/eligibility-impact", 152 params={"domain": domains} if domains else None, 153 ) 154 _raise_for_status(resp) 155 return EligibilityImpact.model_validate(resp.json()) 156 157 def conditions_preview( 158 self, 159 competition_id: str, 160 *, 161 domains: builtins.list[str] | None = None, 162 eligibility_mode: Literal["domains", "mainland_student"] | None = None, 163 carsi_roles: builtins.list[str] | None = None, 164 students_only: bool | None = None, 165 team_entity_policy: Literal["none", "same_institution"] | None = None, 166 min_team_size: int | None = None, 167 max_team_size: int | None = None, 168 exempt_invited: bool | None = None, 169 on_ineligible: Literal["refuse", "review"] | None = None, 170 email_satisfies_role: bool | None = None, 171 ) -> RegistrationConditionsPreview: 172 """Which registered teams unsaved registration conditions would flag (#2767). 173 174 Each argument overrides the stored setting; ``None`` keeps it, and 175 ``domains=[]`` previews "no institution list". Flagged teams are 176 never removed. Names members by display name only, so not a PII read. 177 """ 178 params: dict[str, object] = {} 179 if domains is not None: 180 params["domain"] = domains or [""] 181 if eligibility_mode is not None: 182 params["eligibility_mode"] = eligibility_mode 183 if carsi_roles is not None: 184 params["carsi_role"] = carsi_roles 185 if students_only is not None: 186 params["students_only"] = students_only 187 if team_entity_policy is not None: 188 params["team_entity_policy"] = team_entity_policy 189 if min_team_size is not None: 190 params["min_team_size"] = min_team_size 191 if max_team_size is not None: 192 params["max_team_size"] = max_team_size 193 if exempt_invited is not None: 194 params["exempt_invited"] = exempt_invited 195 if on_ineligible is not None: 196 params["on_ineligible"] = on_ineligible 197 if email_satisfies_role is not None: 198 params["email_satisfies_role"] = email_satisfies_role 199 resp = self._http.request( 200 "GET", 201 f"/admin/competitions/{competition_id}/registration-conditions/preview", 202 params=params or None, 203 ) 204 _raise_for_status(resp) 205 return RegistrationConditionsPreview.model_validate(resp.json()) 206 207 def review( 208 self, 209 competition_id: str, 210 team_id: str, 211 *, 212 status: str, 213 note: str = "", 214 review_token: str = "", 215 items: builtins.list[ReviewTodoItemRequest] | None = None, 216 ) -> RegistrationTeamRow: 217 """Rule on one squad's eligibility. 218 219 ``status`` is ``approved`` / ``rejected`` / ``pending``. A 220 rejection requires a ``note`` — the server rejects an empty one, 221 because "rejected, no reason given" becomes a support ticket every 222 time. Members are emailed the verdict when email is configured. 223 224 ``review_token`` is the roster row's (#2521): with it the verdict 225 lands only if the team has not changed since that row was read, 226 else the server answers ``409 review_stale``. Without it the 227 verdict is written unconditionally. 228 229 ``items`` (rejections only, #2527) become one to-do each for the 230 member they name; the last one done resubmits the team. 231 """ 232 body: dict[str, object] = {"status": status, "note": note} 233 if review_token: 234 body["review_token"] = review_token 235 if items: 236 body["items"] = [i.model_dump(mode="json") for i in items] 237 resp = self._http.request( 238 "POST", 239 f"/admin/competitions/{competition_id}/teams/{team_id}/review", 240 json=body, 241 ) 242 _raise_for_status(resp) 243 return RegistrationTeamRow.model_validate(resp.json()) 244 245 def bulk_approve( 246 self, competition_id: str, teams: builtins.list[tuple[str, str]] 247 ) -> BulkApproveResponse: 248 """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521). 249 250 The server decides per team: one that is not pending, was resubmitted, 251 gained a member, was edited after review, is incomplete, has a member 252 who no longer meets the institution rule, or whose token is stale is 253 skipped and its ``skipped_for`` says why. Read the tokens off 254 :meth:`roster`. 255 """ 256 body = BulkApproveRequest( 257 teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams] 258 ) 259 resp = self._http.request( 260 "POST", 261 f"/admin/competitions/{competition_id}/registration-review/bulk-approve", 262 json=body.model_dump(mode="json"), 263 ) 264 _raise_for_status(resp) 265 return BulkApproveResponse.model_validate(resp.json()) 266 267 def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence: 268 """Say which team this reviewer has open (``""`` = none) and hear which 269 teams the other reviewers have open right now (#2536). 270 271 A soft hint, never a lock: a sighting lapses after the presence TTL 272 without another beat, and nothing here refuses a verdict -- the 273 review token still does that. The caller's own presence is never in 274 the answer. 275 """ 276 body = ReviewPresenceBeat(team_id=team_id) 277 resp = self._http.request( 278 "POST", 279 f"/admin/competitions/{competition_id}/registration-review/presence", 280 json=body.model_dump(mode="json"), 281 ) 282 _raise_for_status(resp) 283 return ReviewPresence.model_validate(resp.json()) 284 285 def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline: 286 """One team's review history with every edit's values before and after, 287 the changes since the last verdict and the reject to-dos (#2524). 288 Audited as a PII read (``review_timeline``) on every call.""" 289 resp = self._http.request( 290 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline" 291 ) 292 _raise_for_status(resp) 293 return ReviewTimeline.model_validate(resp.json()) 294 295 def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence: 296 """Read all bound emails and CARSI evidence for one reviewed team. 297 298 The server gates and audits this on-demand PII read. Provider ids and 299 tokens are deliberately absent from the returned evidence. 300 """ 301 resp = self._http.request( 302 "GET", 303 f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence", 304 ) 305 _raise_for_status(resp) 306 return RegistrationIdentityEvidence.model_validate(resp.json()) 307 308 def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail: 309 """One team's roster row at its own URL, beside the fields the event 310 collects (#2767). Audited as a PII read (``team_detail``) whenever the 311 event collects sensitive fields, exactly as the roster page it is one 312 row of.""" 313 resp = self._http.request( 314 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration" 315 ) 316 _raise_for_status(resp) 317 return RegistrationTeamDetail.model_validate(resp.json()) 318 319 def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes: 320 """The reviewers' private notes on one team, oldest first (#2767). 321 The team never sees them; every read is audited (``review_notes``).""" 322 resp = self._http.request( 323 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes" 324 ) 325 _raise_for_status(resp) 326 return RegistrationReviewNotes.model_validate(resp.json()) 327 328 def add_review_note( 329 self, competition_id: str, team_id: str, body: str 330 ) -> RegistrationReviewNote: 331 """Append one reviewer note to a team (#2767). Append-only: there is 332 no edit and no delete. ``body`` is trimmed server-side and must not 333 be empty.""" 334 resp = self._http.request( 335 "POST", 336 f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes", 337 json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"), 338 ) 339 _raise_for_status(resp) 340 return RegistrationReviewNote.model_validate(resp.json()) 341 342 def edit( 343 self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest 344 ) -> RegistrationTeamRow: 345 """Correct one entrant's dossier on their behalf. 346 347 For the two cases the entrant's own form cannot serve: a detail 348 taken down wrong that only the organiser can see is wrong, and a 349 registration completed over the phone or at a desk. 350 351 **Not gated on the registration deadline** — correcting a record 352 is not entering the competition, and a typo that becomes 353 uncorrectable the moment sign-up closes is uncorrectable for 354 exactly the period the roster gets read. Lands a 355 ``registration_edited`` audit row naming the caller, the subject 356 and which fields moved (never their values). 357 """ 358 resp = self._http.request( 359 "PUT", 360 f"/admin/competitions/{competition_id}/registrations/{user_id}", 361 json=answers.model_dump(mode="json"), 362 ) 363 _raise_for_status(resp) 364 return RegistrationTeamRow.model_validate(resp.json()) 365 366 def export_csv(self, competition_id: str) -> bytes: 367 """The organiser's 基本信息 sheet as CSV bytes. 368 369 UTF-8 **with BOM** so Excel on Windows decodes Chinese names 370 correctly. Write the bytes verbatim — re-encoding through ``str`` 371 will usually drop the BOM and reintroduce the mojibake. 372 """ 373 resp = self._http.request( 374 "GET", f"/admin/competitions/{competition_id}/registration-export" 375 ) 376 _raise_for_status(resp) 377 return resp.content 378 379 # -- rejection wordings (#2746) ------------------------------------------ 380 381 def review_templates(self, competition_id: str) -> RegistrationMessageTemplates: 382 """The competition's rejection wordings, oldest first, and the 383 placeholders a body may use. Reviewers and organisers.""" 384 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates") 385 _raise_for_status(resp) 386 return RegistrationMessageTemplates.model_validate(resp.json()) 387 388 def add_review_template( 389 self, competition_id: str, *, label: dict[str, str], body: dict[str, str] 390 ) -> RegistrationMessageTemplate: 391 """Add one wording; ``label`` and ``body`` are ``{locale: text}``. 392 Organisers only. A body naming a placeholder outside the closed set 393 is refused ``422 review_template_placeholder``.""" 394 resp = self._http.request( 395 "POST", 396 f"/admin/competitions/{competition_id}/review-templates", 397 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 398 ) 399 _raise_for_status(resp) 400 return RegistrationMessageTemplate.model_validate(resp.json()) 401 402 def update_review_template( 403 self, 404 competition_id: str, 405 template_id: str, 406 *, 407 label: dict[str, str], 408 body: dict[str, str], 409 ) -> RegistrationMessageTemplate: 410 """Replace one wording. A rejection already sent keeps its text.""" 411 resp = self._http.request( 412 "PUT", 413 f"/admin/competitions/{competition_id}/review-templates/{template_id}", 414 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 415 ) 416 _raise_for_status(resp) 417 return RegistrationMessageTemplate.model_validate(resp.json()) 418 419 def delete_review_template( 420 self, competition_id: str, template_id: str 421 ) -> RegistrationMessageTemplates: 422 """Remove one wording; returns the set as it now stands.""" 423 resp = self._http.request( 424 "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}" 425 ) 426 _raise_for_status(resp) 427 return RegistrationMessageTemplates.model_validate(resp.json()) 428 429 def render_review_template( 430 self, competition_id: str, team_id: str, template_id: str 431 ) -> RenderedReviewMessage: 432 """One wording filled in for one team, in its captain's mail language.""" 433 resp = self._http.request( 434 "GET", 435 f"/admin/competitions/{competition_id}/teams/{team_id}" 436 f"/review-templates/{template_id}/render", 437 ) 438 _raise_for_status(resp) 439 return RenderedReviewMessage.model_validate(resp.json())
44class AdminRegistrationsResource: 45 def __init__(self, http: BaseHttpClient) -> None: 46 self._http = http 47 48 def roster( 49 self, 50 competition_id: str, 51 *, 52 status: str = "", 53 eligibility: str = "", 54 q: str = "", 55 sort: str = "", 56 waitlist: bool = False, 57 offset: int = 0, 58 limit: int = 50, 59 ) -> builtins.list[RegistrationTeamRow]: 60 """One page of the roster with entrant details, grouped by team. 61 62 Paged and verdict-filtered server-side: each dossier costs an 63 AES-GCM open per sealed field, so a thousand-entrant competition 64 cannot afford "fetch everything and filter here" — which is 65 exactly what this used to do. 66 67 ``status`` is ``pending`` / ``approved`` / ``rejected``; empty 68 means every verdict. ``eligibility`` is ``verified`` / ``invited`` / 69 ``lapsed`` and keeps the squads with at least one member so marked 70 (#2184; empty = every squad). ``q`` searches team name, player id, 71 nickname, e-mail and real name (#2518); ``sort`` is ``created`` 72 (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order, 73 then the page, all over the whole competition. ``waitlist`` keeps 74 only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all` 75 to walk the whole roster. Each call is audited as a PII read. 76 """ 77 params: dict[str, object] = {"offset": offset, "limit": limit} 78 if status: 79 params["status"] = status 80 if eligibility: 81 params["eligibility"] = eligibility 82 if q: 83 params["q"] = q 84 if sort: 85 params["sort"] = sort 86 if waitlist: 87 params["waitlist"] = True 88 resp = self._http.request( 89 "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params 90 ) 91 _raise_for_status(resp) 92 return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]] 93 94 def roster_all( 95 self, competition_id: str, *, status: str = "", page_size: int = 200 96 ) -> builtins.list[RegistrationTeamRow]: 97 """Every page, concatenated — for scripts that genuinely want all. 98 99 Kept explicit rather than making :meth:`roster` unbounded, so 100 "walk the whole roster" is a decision a caller makes rather than 101 the default cost of asking for any of it. Note each page lands 102 its own audit row, which is correct: this reads every entrant's 103 personal data. 104 """ 105 out: builtins.list[RegistrationTeamRow] = [] 106 offset = 0 107 while True: 108 page = self.roster(competition_id, status=status, offset=offset, limit=page_size) 109 out.extend(page) 110 if len(page) < page_size: 111 return out 112 offset += page_size 113 114 def summary(self, competition_id: str) -> RegistrationSummary: 115 """Per-verdict totals for the whole competition. 116 117 What the roster's filter tabs are labelled with — counting a page 118 would report the page size. Carries no personal data, so unlike 119 :meth:`roster` it is not audited. 120 """ 121 resp = self._http.request( 122 "GET", f"/admin/competitions/{competition_id}/registration-summary" 123 ) 124 _raise_for_status(resp) 125 return RegistrationSummary.model_validate(resp.json()) 126 127 def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult: 128 """Move waitlisted teams onto the free places, in queue order (#2767). 129 130 ``limit`` caps how many move (``0`` = as many as are free). The 131 server counts and promotes in one statement, so two reviewers 132 backfilling at once never overfill. Promoted teams stay ``pending``. 133 """ 134 resp = self._http.request( 135 "POST", 136 f"/admin/competitions/{competition_id}/registration-waitlist/backfill", 137 json=WaitlistBackfillRequest(limit=limit).model_dump(), 138 ) 139 _raise_for_status(resp) 140 return WaitlistBackfillResult.model_validate(resp.json()) 141 142 def eligibility_impact( 143 self, competition_id: str, *, domains: builtins.list[str] | None = None 144 ) -> EligibilityImpact: 145 """What an institution rule means for the people already registered (#2184). 146 147 Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is 148 not a PII read. ``domains`` previews a set instead of the stored one. 149 """ 150 resp = self._http.request( 151 "GET", 152 f"/admin/competitions/{competition_id}/eligibility-impact", 153 params={"domain": domains} if domains else None, 154 ) 155 _raise_for_status(resp) 156 return EligibilityImpact.model_validate(resp.json()) 157 158 def conditions_preview( 159 self, 160 competition_id: str, 161 *, 162 domains: builtins.list[str] | None = None, 163 eligibility_mode: Literal["domains", "mainland_student"] | None = None, 164 carsi_roles: builtins.list[str] | None = None, 165 students_only: bool | None = None, 166 team_entity_policy: Literal["none", "same_institution"] | None = None, 167 min_team_size: int | None = None, 168 max_team_size: int | None = None, 169 exempt_invited: bool | None = None, 170 on_ineligible: Literal["refuse", "review"] | None = None, 171 email_satisfies_role: bool | None = None, 172 ) -> RegistrationConditionsPreview: 173 """Which registered teams unsaved registration conditions would flag (#2767). 174 175 Each argument overrides the stored setting; ``None`` keeps it, and 176 ``domains=[]`` previews "no institution list". Flagged teams are 177 never removed. Names members by display name only, so not a PII read. 178 """ 179 params: dict[str, object] = {} 180 if domains is not None: 181 params["domain"] = domains or [""] 182 if eligibility_mode is not None: 183 params["eligibility_mode"] = eligibility_mode 184 if carsi_roles is not None: 185 params["carsi_role"] = carsi_roles 186 if students_only is not None: 187 params["students_only"] = students_only 188 if team_entity_policy is not None: 189 params["team_entity_policy"] = team_entity_policy 190 if min_team_size is not None: 191 params["min_team_size"] = min_team_size 192 if max_team_size is not None: 193 params["max_team_size"] = max_team_size 194 if exempt_invited is not None: 195 params["exempt_invited"] = exempt_invited 196 if on_ineligible is not None: 197 params["on_ineligible"] = on_ineligible 198 if email_satisfies_role is not None: 199 params["email_satisfies_role"] = email_satisfies_role 200 resp = self._http.request( 201 "GET", 202 f"/admin/competitions/{competition_id}/registration-conditions/preview", 203 params=params or None, 204 ) 205 _raise_for_status(resp) 206 return RegistrationConditionsPreview.model_validate(resp.json()) 207 208 def review( 209 self, 210 competition_id: str, 211 team_id: str, 212 *, 213 status: str, 214 note: str = "", 215 review_token: str = "", 216 items: builtins.list[ReviewTodoItemRequest] | None = None, 217 ) -> RegistrationTeamRow: 218 """Rule on one squad's eligibility. 219 220 ``status`` is ``approved`` / ``rejected`` / ``pending``. A 221 rejection requires a ``note`` — the server rejects an empty one, 222 because "rejected, no reason given" becomes a support ticket every 223 time. Members are emailed the verdict when email is configured. 224 225 ``review_token`` is the roster row's (#2521): with it the verdict 226 lands only if the team has not changed since that row was read, 227 else the server answers ``409 review_stale``. Without it the 228 verdict is written unconditionally. 229 230 ``items`` (rejections only, #2527) become one to-do each for the 231 member they name; the last one done resubmits the team. 232 """ 233 body: dict[str, object] = {"status": status, "note": note} 234 if review_token: 235 body["review_token"] = review_token 236 if items: 237 body["items"] = [i.model_dump(mode="json") for i in items] 238 resp = self._http.request( 239 "POST", 240 f"/admin/competitions/{competition_id}/teams/{team_id}/review", 241 json=body, 242 ) 243 _raise_for_status(resp) 244 return RegistrationTeamRow.model_validate(resp.json()) 245 246 def bulk_approve( 247 self, competition_id: str, teams: builtins.list[tuple[str, str]] 248 ) -> BulkApproveResponse: 249 """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521). 250 251 The server decides per team: one that is not pending, was resubmitted, 252 gained a member, was edited after review, is incomplete, has a member 253 who no longer meets the institution rule, or whose token is stale is 254 skipped and its ``skipped_for`` says why. Read the tokens off 255 :meth:`roster`. 256 """ 257 body = BulkApproveRequest( 258 teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams] 259 ) 260 resp = self._http.request( 261 "POST", 262 f"/admin/competitions/{competition_id}/registration-review/bulk-approve", 263 json=body.model_dump(mode="json"), 264 ) 265 _raise_for_status(resp) 266 return BulkApproveResponse.model_validate(resp.json()) 267 268 def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence: 269 """Say which team this reviewer has open (``""`` = none) and hear which 270 teams the other reviewers have open right now (#2536). 271 272 A soft hint, never a lock: a sighting lapses after the presence TTL 273 without another beat, and nothing here refuses a verdict -- the 274 review token still does that. The caller's own presence is never in 275 the answer. 276 """ 277 body = ReviewPresenceBeat(team_id=team_id) 278 resp = self._http.request( 279 "POST", 280 f"/admin/competitions/{competition_id}/registration-review/presence", 281 json=body.model_dump(mode="json"), 282 ) 283 _raise_for_status(resp) 284 return ReviewPresence.model_validate(resp.json()) 285 286 def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline: 287 """One team's review history with every edit's values before and after, 288 the changes since the last verdict and the reject to-dos (#2524). 289 Audited as a PII read (``review_timeline``) on every call.""" 290 resp = self._http.request( 291 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline" 292 ) 293 _raise_for_status(resp) 294 return ReviewTimeline.model_validate(resp.json()) 295 296 def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence: 297 """Read all bound emails and CARSI evidence for one reviewed team. 298 299 The server gates and audits this on-demand PII read. Provider ids and 300 tokens are deliberately absent from the returned evidence. 301 """ 302 resp = self._http.request( 303 "GET", 304 f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence", 305 ) 306 _raise_for_status(resp) 307 return RegistrationIdentityEvidence.model_validate(resp.json()) 308 309 def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail: 310 """One team's roster row at its own URL, beside the fields the event 311 collects (#2767). Audited as a PII read (``team_detail``) whenever the 312 event collects sensitive fields, exactly as the roster page it is one 313 row of.""" 314 resp = self._http.request( 315 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration" 316 ) 317 _raise_for_status(resp) 318 return RegistrationTeamDetail.model_validate(resp.json()) 319 320 def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes: 321 """The reviewers' private notes on one team, oldest first (#2767). 322 The team never sees them; every read is audited (``review_notes``).""" 323 resp = self._http.request( 324 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes" 325 ) 326 _raise_for_status(resp) 327 return RegistrationReviewNotes.model_validate(resp.json()) 328 329 def add_review_note( 330 self, competition_id: str, team_id: str, body: str 331 ) -> RegistrationReviewNote: 332 """Append one reviewer note to a team (#2767). Append-only: there is 333 no edit and no delete. ``body`` is trimmed server-side and must not 334 be empty.""" 335 resp = self._http.request( 336 "POST", 337 f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes", 338 json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"), 339 ) 340 _raise_for_status(resp) 341 return RegistrationReviewNote.model_validate(resp.json()) 342 343 def edit( 344 self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest 345 ) -> RegistrationTeamRow: 346 """Correct one entrant's dossier on their behalf. 347 348 For the two cases the entrant's own form cannot serve: a detail 349 taken down wrong that only the organiser can see is wrong, and a 350 registration completed over the phone or at a desk. 351 352 **Not gated on the registration deadline** — correcting a record 353 is not entering the competition, and a typo that becomes 354 uncorrectable the moment sign-up closes is uncorrectable for 355 exactly the period the roster gets read. Lands a 356 ``registration_edited`` audit row naming the caller, the subject 357 and which fields moved (never their values). 358 """ 359 resp = self._http.request( 360 "PUT", 361 f"/admin/competitions/{competition_id}/registrations/{user_id}", 362 json=answers.model_dump(mode="json"), 363 ) 364 _raise_for_status(resp) 365 return RegistrationTeamRow.model_validate(resp.json()) 366 367 def export_csv(self, competition_id: str) -> bytes: 368 """The organiser's 基本信息 sheet as CSV bytes. 369 370 UTF-8 **with BOM** so Excel on Windows decodes Chinese names 371 correctly. Write the bytes verbatim — re-encoding through ``str`` 372 will usually drop the BOM and reintroduce the mojibake. 373 """ 374 resp = self._http.request( 375 "GET", f"/admin/competitions/{competition_id}/registration-export" 376 ) 377 _raise_for_status(resp) 378 return resp.content 379 380 # -- rejection wordings (#2746) ------------------------------------------ 381 382 def review_templates(self, competition_id: str) -> RegistrationMessageTemplates: 383 """The competition's rejection wordings, oldest first, and the 384 placeholders a body may use. Reviewers and organisers.""" 385 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates") 386 _raise_for_status(resp) 387 return RegistrationMessageTemplates.model_validate(resp.json()) 388 389 def add_review_template( 390 self, competition_id: str, *, label: dict[str, str], body: dict[str, str] 391 ) -> RegistrationMessageTemplate: 392 """Add one wording; ``label`` and ``body`` are ``{locale: text}``. 393 Organisers only. A body naming a placeholder outside the closed set 394 is refused ``422 review_template_placeholder``.""" 395 resp = self._http.request( 396 "POST", 397 f"/admin/competitions/{competition_id}/review-templates", 398 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 399 ) 400 _raise_for_status(resp) 401 return RegistrationMessageTemplate.model_validate(resp.json()) 402 403 def update_review_template( 404 self, 405 competition_id: str, 406 template_id: str, 407 *, 408 label: dict[str, str], 409 body: dict[str, str], 410 ) -> RegistrationMessageTemplate: 411 """Replace one wording. A rejection already sent keeps its text.""" 412 resp = self._http.request( 413 "PUT", 414 f"/admin/competitions/{competition_id}/review-templates/{template_id}", 415 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 416 ) 417 _raise_for_status(resp) 418 return RegistrationMessageTemplate.model_validate(resp.json()) 419 420 def delete_review_template( 421 self, competition_id: str, template_id: str 422 ) -> RegistrationMessageTemplates: 423 """Remove one wording; returns the set as it now stands.""" 424 resp = self._http.request( 425 "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}" 426 ) 427 _raise_for_status(resp) 428 return RegistrationMessageTemplates.model_validate(resp.json()) 429 430 def render_review_template( 431 self, competition_id: str, team_id: str, template_id: str 432 ) -> RenderedReviewMessage: 433 """One wording filled in for one team, in its captain's mail language.""" 434 resp = self._http.request( 435 "GET", 436 f"/admin/competitions/{competition_id}/teams/{team_id}" 437 f"/review-templates/{template_id}/render", 438 ) 439 _raise_for_status(resp) 440 return RenderedReviewMessage.model_validate(resp.json())
48 def roster( 49 self, 50 competition_id: str, 51 *, 52 status: str = "", 53 eligibility: str = "", 54 q: str = "", 55 sort: str = "", 56 waitlist: bool = False, 57 offset: int = 0, 58 limit: int = 50, 59 ) -> builtins.list[RegistrationTeamRow]: 60 """One page of the roster with entrant details, grouped by team. 61 62 Paged and verdict-filtered server-side: each dossier costs an 63 AES-GCM open per sealed field, so a thousand-entrant competition 64 cannot afford "fetch everything and filter here" — which is 65 exactly what this used to do. 66 67 ``status`` is ``pending`` / ``approved`` / ``rejected``; empty 68 means every verdict. ``eligibility`` is ``verified`` / ``invited`` / 69 ``lapsed`` and keeps the squads with at least one member so marked 70 (#2184; empty = every squad). ``q`` searches team name, player id, 71 nickname, e-mail and real name (#2518); ``sort`` is ``created`` 72 (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order, 73 then the page, all over the whole competition. ``waitlist`` keeps 74 only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all` 75 to walk the whole roster. Each call is audited as a PII read. 76 """ 77 params: dict[str, object] = {"offset": offset, "limit": limit} 78 if status: 79 params["status"] = status 80 if eligibility: 81 params["eligibility"] = eligibility 82 if q: 83 params["q"] = q 84 if sort: 85 params["sort"] = sort 86 if waitlist: 87 params["waitlist"] = True 88 resp = self._http.request( 89 "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params 90 ) 91 _raise_for_status(resp) 92 return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]
One page of the roster with entrant details, grouped by team.
Paged and verdict-filtered server-side: each dossier costs an AES-GCM open per sealed field, so a thousand-entrant competition cannot afford "fetch everything and filter here" — which is exactly what this used to do.
status is pending / approved / rejected; empty
means every verdict. eligibility is verified / invited /
lapsed and keeps the squads with at least one member so marked
(#2184; empty = every squad). q searches team name, player id,
nickname, e-mail and real name (#2518); sort is created
(default) / waiting / recent (#2524). Filters, then the order,
then the page, all over the whole competition. waitlist keeps
only the waitlisted teams, in queue order (#2767). Use roster_all()
to walk the whole roster. Each call is audited as a PII read.
94 def roster_all( 95 self, competition_id: str, *, status: str = "", page_size: int = 200 96 ) -> builtins.list[RegistrationTeamRow]: 97 """Every page, concatenated — for scripts that genuinely want all. 98 99 Kept explicit rather than making :meth:`roster` unbounded, so 100 "walk the whole roster" is a decision a caller makes rather than 101 the default cost of asking for any of it. Note each page lands 102 its own audit row, which is correct: this reads every entrant's 103 personal data. 104 """ 105 out: builtins.list[RegistrationTeamRow] = [] 106 offset = 0 107 while True: 108 page = self.roster(competition_id, status=status, offset=offset, limit=page_size) 109 out.extend(page) 110 if len(page) < page_size: 111 return out 112 offset += page_size
Every page, concatenated — for scripts that genuinely want all.
Kept explicit rather than making roster() unbounded, so
"walk the whole roster" is a decision a caller makes rather than
the default cost of asking for any of it. Note each page lands
its own audit row, which is correct: this reads every entrant's
personal data.
114 def summary(self, competition_id: str) -> RegistrationSummary: 115 """Per-verdict totals for the whole competition. 116 117 What the roster's filter tabs are labelled with — counting a page 118 would report the page size. Carries no personal data, so unlike 119 :meth:`roster` it is not audited. 120 """ 121 resp = self._http.request( 122 "GET", f"/admin/competitions/{competition_id}/registration-summary" 123 ) 124 _raise_for_status(resp) 125 return RegistrationSummary.model_validate(resp.json())
Per-verdict totals for the whole competition.
What the roster's filter tabs are labelled with — counting a page
would report the page size. Carries no personal data, so unlike
roster() it is not audited.
127 def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult: 128 """Move waitlisted teams onto the free places, in queue order (#2767). 129 130 ``limit`` caps how many move (``0`` = as many as are free). The 131 server counts and promotes in one statement, so two reviewers 132 backfilling at once never overfill. Promoted teams stay ``pending``. 133 """ 134 resp = self._http.request( 135 "POST", 136 f"/admin/competitions/{competition_id}/registration-waitlist/backfill", 137 json=WaitlistBackfillRequest(limit=limit).model_dump(), 138 ) 139 _raise_for_status(resp) 140 return WaitlistBackfillResult.model_validate(resp.json())
Move waitlisted teams onto the free places, in queue order (#2767).
limit caps how many move (0 = as many as are free). The
server counts and promotes in one statement, so two reviewers
backfilling at once never overfill. Promoted teams stay pending.
142 def eligibility_impact( 143 self, competition_id: str, *, domains: builtins.list[str] | None = None 144 ) -> EligibilityImpact: 145 """What an institution rule means for the people already registered (#2184). 146 147 Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is 148 not a PII read. ``domains`` previews a set instead of the stored one. 149 """ 150 resp = self._http.request( 151 "GET", 152 f"/admin/competitions/{competition_id}/eligibility-impact", 153 params={"domain": domains} if domains else None, 154 ) 155 _raise_for_status(resp) 156 return EligibilityImpact.model_validate(resp.json())
What an institution rule means for the people already registered (#2184).
Counts only -- verified / invited / not_meeting -- so it is
not a PII read. domains previews a set instead of the stored one.
158 def conditions_preview( 159 self, 160 competition_id: str, 161 *, 162 domains: builtins.list[str] | None = None, 163 eligibility_mode: Literal["domains", "mainland_student"] | None = None, 164 carsi_roles: builtins.list[str] | None = None, 165 students_only: bool | None = None, 166 team_entity_policy: Literal["none", "same_institution"] | None = None, 167 min_team_size: int | None = None, 168 max_team_size: int | None = None, 169 exempt_invited: bool | None = None, 170 on_ineligible: Literal["refuse", "review"] | None = None, 171 email_satisfies_role: bool | None = None, 172 ) -> RegistrationConditionsPreview: 173 """Which registered teams unsaved registration conditions would flag (#2767). 174 175 Each argument overrides the stored setting; ``None`` keeps it, and 176 ``domains=[]`` previews "no institution list". Flagged teams are 177 never removed. Names members by display name only, so not a PII read. 178 """ 179 params: dict[str, object] = {} 180 if domains is not None: 181 params["domain"] = domains or [""] 182 if eligibility_mode is not None: 183 params["eligibility_mode"] = eligibility_mode 184 if carsi_roles is not None: 185 params["carsi_role"] = carsi_roles 186 if students_only is not None: 187 params["students_only"] = students_only 188 if team_entity_policy is not None: 189 params["team_entity_policy"] = team_entity_policy 190 if min_team_size is not None: 191 params["min_team_size"] = min_team_size 192 if max_team_size is not None: 193 params["max_team_size"] = max_team_size 194 if exempt_invited is not None: 195 params["exempt_invited"] = exempt_invited 196 if on_ineligible is not None: 197 params["on_ineligible"] = on_ineligible 198 if email_satisfies_role is not None: 199 params["email_satisfies_role"] = email_satisfies_role 200 resp = self._http.request( 201 "GET", 202 f"/admin/competitions/{competition_id}/registration-conditions/preview", 203 params=params or None, 204 ) 205 _raise_for_status(resp) 206 return RegistrationConditionsPreview.model_validate(resp.json())
Which registered teams unsaved registration conditions would flag (#2767).
Each argument overrides the stored setting; None keeps it, and
domains=[] previews "no institution list". Flagged teams are
never removed. Names members by display name only, so not a PII read.
208 def review( 209 self, 210 competition_id: str, 211 team_id: str, 212 *, 213 status: str, 214 note: str = "", 215 review_token: str = "", 216 items: builtins.list[ReviewTodoItemRequest] | None = None, 217 ) -> RegistrationTeamRow: 218 """Rule on one squad's eligibility. 219 220 ``status`` is ``approved`` / ``rejected`` / ``pending``. A 221 rejection requires a ``note`` — the server rejects an empty one, 222 because "rejected, no reason given" becomes a support ticket every 223 time. Members are emailed the verdict when email is configured. 224 225 ``review_token`` is the roster row's (#2521): with it the verdict 226 lands only if the team has not changed since that row was read, 227 else the server answers ``409 review_stale``. Without it the 228 verdict is written unconditionally. 229 230 ``items`` (rejections only, #2527) become one to-do each for the 231 member they name; the last one done resubmits the team. 232 """ 233 body: dict[str, object] = {"status": status, "note": note} 234 if review_token: 235 body["review_token"] = review_token 236 if items: 237 body["items"] = [i.model_dump(mode="json") for i in items] 238 resp = self._http.request( 239 "POST", 240 f"/admin/competitions/{competition_id}/teams/{team_id}/review", 241 json=body, 242 ) 243 _raise_for_status(resp) 244 return RegistrationTeamRow.model_validate(resp.json())
Rule on one squad's eligibility.
status is approved / rejected / pending. A
rejection requires a note — the server rejects an empty one,
because "rejected, no reason given" becomes a support ticket every
time. Members are emailed the verdict when email is configured.
review_token is the roster row's (#2521): with it the verdict
lands only if the team has not changed since that row was read,
else the server answers 409 review_stale. Without it the
verdict is written unconditionally.
items (rejections only, #2527) become one to-do each for the
member they name; the last one done resubmits the team.
246 def bulk_approve( 247 self, competition_id: str, teams: builtins.list[tuple[str, str]] 248 ) -> BulkApproveResponse: 249 """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521). 250 251 The server decides per team: one that is not pending, was resubmitted, 252 gained a member, was edited after review, is incomplete, has a member 253 who no longer meets the institution rule, or whose token is stale is 254 skipped and its ``skipped_for`` says why. Read the tokens off 255 :meth:`roster`. 256 """ 257 body = BulkApproveRequest( 258 teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams] 259 ) 260 resp = self._http.request( 261 "POST", 262 f"/admin/competitions/{competition_id}/registration-review/bulk-approve", 263 json=body.model_dump(mode="json"), 264 ) 265 _raise_for_status(resp) 266 return BulkApproveResponse.model_validate(resp.json())
Approve the given (team_id, review_token) pairs that carry no marker (#2521).
The server decides per team: one that is not pending, was resubmitted,
gained a member, was edited after review, is incomplete, has a member
who no longer meets the institution rule, or whose token is stale is
skipped and its skipped_for says why. Read the tokens off
roster().
268 def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence: 269 """Say which team this reviewer has open (``""`` = none) and hear which 270 teams the other reviewers have open right now (#2536). 271 272 A soft hint, never a lock: a sighting lapses after the presence TTL 273 without another beat, and nothing here refuses a verdict -- the 274 review token still does that. The caller's own presence is never in 275 the answer. 276 """ 277 body = ReviewPresenceBeat(team_id=team_id) 278 resp = self._http.request( 279 "POST", 280 f"/admin/competitions/{competition_id}/registration-review/presence", 281 json=body.model_dump(mode="json"), 282 ) 283 _raise_for_status(resp) 284 return ReviewPresence.model_validate(resp.json())
Say which team this reviewer has open ("" = none) and hear which
teams the other reviewers have open right now (#2536).
A soft hint, never a lock: a sighting lapses after the presence TTL without another beat, and nothing here refuses a verdict -- the review token still does that. The caller's own presence is never in the answer.
286 def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline: 287 """One team's review history with every edit's values before and after, 288 the changes since the last verdict and the reject to-dos (#2524). 289 Audited as a PII read (``review_timeline``) on every call.""" 290 resp = self._http.request( 291 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline" 292 ) 293 _raise_for_status(resp) 294 return ReviewTimeline.model_validate(resp.json())
One team's review history with every edit's values before and after,
the changes since the last verdict and the reject to-dos (#2524).
Audited as a PII read (review_timeline) on every call.
296 def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence: 297 """Read all bound emails and CARSI evidence for one reviewed team. 298 299 The server gates and audits this on-demand PII read. Provider ids and 300 tokens are deliberately absent from the returned evidence. 301 """ 302 resp = self._http.request( 303 "GET", 304 f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence", 305 ) 306 _raise_for_status(resp) 307 return RegistrationIdentityEvidence.model_validate(resp.json())
Read all bound emails and CARSI evidence for one reviewed team.
The server gates and audits this on-demand PII read. Provider ids and tokens are deliberately absent from the returned evidence.
309 def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail: 310 """One team's roster row at its own URL, beside the fields the event 311 collects (#2767). Audited as a PII read (``team_detail``) whenever the 312 event collects sensitive fields, exactly as the roster page it is one 313 row of.""" 314 resp = self._http.request( 315 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration" 316 ) 317 _raise_for_status(resp) 318 return RegistrationTeamDetail.model_validate(resp.json())
One team's roster row at its own URL, beside the fields the event
collects (#2767). Audited as a PII read (team_detail) whenever the
event collects sensitive fields, exactly as the roster page it is one
row of.
320 def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes: 321 """The reviewers' private notes on one team, oldest first (#2767). 322 The team never sees them; every read is audited (``review_notes``).""" 323 resp = self._http.request( 324 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes" 325 ) 326 _raise_for_status(resp) 327 return RegistrationReviewNotes.model_validate(resp.json())
The reviewers' private notes on one team, oldest first (#2767).
The team never sees them; every read is audited (review_notes).
329 def add_review_note( 330 self, competition_id: str, team_id: str, body: str 331 ) -> RegistrationReviewNote: 332 """Append one reviewer note to a team (#2767). Append-only: there is 333 no edit and no delete. ``body`` is trimmed server-side and must not 334 be empty.""" 335 resp = self._http.request( 336 "POST", 337 f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes", 338 json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"), 339 ) 340 _raise_for_status(resp) 341 return RegistrationReviewNote.model_validate(resp.json())
Append one reviewer note to a team (#2767). Append-only: there is
no edit and no delete. body is trimmed server-side and must not
be empty.
343 def edit( 344 self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest 345 ) -> RegistrationTeamRow: 346 """Correct one entrant's dossier on their behalf. 347 348 For the two cases the entrant's own form cannot serve: a detail 349 taken down wrong that only the organiser can see is wrong, and a 350 registration completed over the phone or at a desk. 351 352 **Not gated on the registration deadline** — correcting a record 353 is not entering the competition, and a typo that becomes 354 uncorrectable the moment sign-up closes is uncorrectable for 355 exactly the period the roster gets read. Lands a 356 ``registration_edited`` audit row naming the caller, the subject 357 and which fields moved (never their values). 358 """ 359 resp = self._http.request( 360 "PUT", 361 f"/admin/competitions/{competition_id}/registrations/{user_id}", 362 json=answers.model_dump(mode="json"), 363 ) 364 _raise_for_status(resp) 365 return RegistrationTeamRow.model_validate(resp.json())
Correct one entrant's dossier on their behalf.
For the two cases the entrant's own form cannot serve: a detail taken down wrong that only the organiser can see is wrong, and a registration completed over the phone or at a desk.
Not gated on the registration deadline — correcting a record
is not entering the competition, and a typo that becomes
uncorrectable the moment sign-up closes is uncorrectable for
exactly the period the roster gets read. Lands a
registration_edited audit row naming the caller, the subject
and which fields moved (never their values).
367 def export_csv(self, competition_id: str) -> bytes: 368 """The organiser's 基本信息 sheet as CSV bytes. 369 370 UTF-8 **with BOM** so Excel on Windows decodes Chinese names 371 correctly. Write the bytes verbatim — re-encoding through ``str`` 372 will usually drop the BOM and reintroduce the mojibake. 373 """ 374 resp = self._http.request( 375 "GET", f"/admin/competitions/{competition_id}/registration-export" 376 ) 377 _raise_for_status(resp) 378 return resp.content
The organiser's 基本信息 sheet as CSV bytes.
UTF-8 with BOM so Excel on Windows decodes Chinese names
correctly. Write the bytes verbatim — re-encoding through str
will usually drop the BOM and reintroduce the mojibake.
382 def review_templates(self, competition_id: str) -> RegistrationMessageTemplates: 383 """The competition's rejection wordings, oldest first, and the 384 placeholders a body may use. Reviewers and organisers.""" 385 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates") 386 _raise_for_status(resp) 387 return RegistrationMessageTemplates.model_validate(resp.json())
The competition's rejection wordings, oldest first, and the placeholders a body may use. Reviewers and organisers.
389 def add_review_template( 390 self, competition_id: str, *, label: dict[str, str], body: dict[str, str] 391 ) -> RegistrationMessageTemplate: 392 """Add one wording; ``label`` and ``body`` are ``{locale: text}``. 393 Organisers only. A body naming a placeholder outside the closed set 394 is refused ``422 review_template_placeholder``.""" 395 resp = self._http.request( 396 "POST", 397 f"/admin/competitions/{competition_id}/review-templates", 398 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 399 ) 400 _raise_for_status(resp) 401 return RegistrationMessageTemplate.model_validate(resp.json())
Add one wording; label and body are {locale: text}.
Organisers only. A body naming a placeholder outside the closed set
is refused 422 review_template_placeholder.
403 def update_review_template( 404 self, 405 competition_id: str, 406 template_id: str, 407 *, 408 label: dict[str, str], 409 body: dict[str, str], 410 ) -> RegistrationMessageTemplate: 411 """Replace one wording. A rejection already sent keeps its text.""" 412 resp = self._http.request( 413 "PUT", 414 f"/admin/competitions/{competition_id}/review-templates/{template_id}", 415 json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"), 416 ) 417 _raise_for_status(resp) 418 return RegistrationMessageTemplate.model_validate(resp.json())
Replace one wording. A rejection already sent keeps its text.
420 def delete_review_template( 421 self, competition_id: str, template_id: str 422 ) -> RegistrationMessageTemplates: 423 """Remove one wording; returns the set as it now stands.""" 424 resp = self._http.request( 425 "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}" 426 ) 427 _raise_for_status(resp) 428 return RegistrationMessageTemplates.model_validate(resp.json())
Remove one wording; returns the set as it now stands.
430 def render_review_template( 431 self, competition_id: str, team_id: str, template_id: str 432 ) -> RenderedReviewMessage: 433 """One wording filled in for one team, in its captain's mail language.""" 434 resp = self._http.request( 435 "GET", 436 f"/admin/competitions/{competition_id}/teams/{team_id}" 437 f"/review-templates/{template_id}/render", 438 ) 439 _raise_for_status(resp) 440 return RenderedReviewMessage.model_validate(resp.json())
One wording filled in for one team, in its captain's mail language.