ctfy.sdk.admin_resources.teams
client.admin.teams — the organiser's write surface on a squad.
Distinct from client.admin.registrations, which is the eligibility
view: roster, verdict, export. This is enforcement — deciding a team is
out of the event, and putting them back.
The two look adjacent and are not substitutes. A review verdict decides prizes and never blocks play; a disqualification blocks every progress path and removes the team from the ranking. A team can be approved and disqualified at once, which is the ordinary shape of "clean at sign-up, caught on day two".
Gated server-side on require_competition_admin — a reviewer's grant
rules on eligibility, and removing a team from a running event is a
heavier decision than the role was cut for.
1"""``client.admin.teams`` — the organiser's write surface on a squad. 2 3Distinct from ``client.admin.registrations``, which is the *eligibility* 4view: roster, verdict, export. This is enforcement — deciding a team is 5out of the event, and putting them back. 6 7The two look adjacent and are not substitutes. A review verdict decides 8prizes and never blocks play; a disqualification blocks every progress 9path and removes the team from the ranking. A team can be approved and 10disqualified at once, which is the ordinary shape of "clean at sign-up, 11caught on day two". 12 13Gated server-side on ``require_competition_admin`` — a reviewer's grant 14rules on eligibility, and removing a team from a running event is a 15heavier decision than the role was cut for. 16""" 17 18from __future__ import annotations 19 20import builtins 21 22from ctfy.sdk._helpers import _raise_for_status 23from ctfy.sdk.base import BaseHttpClient 24from ctfy.server.models import ( 25 AdminTeamRow, 26 BatchAddMembersResponse, 27 CompetitionBanRow, 28 PromotionResponse, 29 RestoreResult, 30 RiskActionRow, 31 TeamDetail, 32 TeamSolveRow, 33 TeamStandingInfo, 34 VoidPreview, 35 VoidResult, 36) 37 38 39class AdminTeamsResource: 40 def __init__(self, http: BaseHttpClient) -> None: 41 self._http = http 42 43 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 44 """Every team in one competition, with its standing. 45 46 Deliberately not the review roster: that decrypts every member's 47 personal details and lands an audit row for the bulk read. 48 Managing a roster needs names and membership, not phone numbers. 49 """ 50 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 51 _raise_for_status(resp) 52 return [AdminTeamRow.model_validate(row) for row in resp.json()] 53 54 def add_member( 55 self, 56 competition_id: str, 57 *, 58 user_id: str = "", 59 email: str = "", 60 team_id: str = "", 61 team_name: str = "", 62 ) -> TeamDetail: 63 """Put a person into the competition directly — no invitation for 64 them to accept, no registration for them to fill in. 65 66 Name them by ``user_id`` or ``email`` (they must have an account). 67 ``team_id`` places them on that existing team; without it a new 68 team is created with them as captain, named ``team_name`` or the 69 solo default. One team per competition still holds (409 70 ``user_already_in_competition``), and so does the team-size cap 71 (409 ``team_full``). They are mailed that it happened. 72 """ 73 body: dict[str, str] = {} 74 if user_id: 75 body["user_id"] = user_id 76 if email: 77 body["email"] = email 78 if team_id: 79 body["team_id"] = team_id 80 if team_name: 81 body["team_name"] = team_name 82 resp = self._http.request( 83 "POST", f"/admin/competitions/{competition_id}/members", json=body 84 ) 85 _raise_for_status(resp) 86 return TeamDetail.model_validate(resp.json()) 87 88 def add_members( 89 self, competition_id: str, text: str, *, dry_run: bool = False 90 ) -> BatchAddMembersResponse: 91 """Add a list of people in one call (#2078) — partial success. 92 93 ``text`` is a pasted column or a CSV file's contents; the server 94 splits it, so every client agrees on the rules. Columns are 95 ``email, team, captain`` (a header row naming them is optional). 96 Rows sharing a team name form one team, the flagged captain (else 97 the first row) founding it. Each row gets its own coded 98 ``outcome``; an address with no account becomes an email 99 invitation (``invited_by_email``). ``dry_run=True`` classifies 100 without writing. Over 500 rows is a 422 ``too_many_rows``. 101 """ 102 resp = self._http.request( 103 "POST", 104 f"/admin/competitions/{competition_id}/members/batch", 105 json={"text": text, "dry_run": dry_run}, 106 ) 107 _raise_for_status(resp) 108 return BatchAddMembersResponse.model_validate(resp.json()) 109 110 def promote( 111 self, 112 competition_id: str, 113 source_competition_id: str, 114 top_n: int, 115 *, 116 exclude_team_ids: builtins.list[str] | None = None, 117 dry_run: bool = False, 118 ) -> PromotionResponse: 119 """Bring the top ``top_n`` teams of another competition's final board 120 into this one (#2120), with their members and captains. 121 122 Every team tied at the cutoff is listed and flagged 123 (``tied_at_cutoff``); ``exclude_team_ids`` leaves teams out, never 124 backfilled. ``dry_run=True`` is the preview. The caller must organise 125 both competitions (403 ``promotion_source_forbidden``); the import 126 waits for the source's end (409 ``promotion_source_not_over``). 127 """ 128 resp = self._http.request( 129 "POST", 130 f"/admin/competitions/{competition_id}/promotion", 131 json={ 132 "source_competition_id": source_competition_id, 133 "top_n": top_n, 134 "exclude_team_ids": list(exclude_team_ids or []), 135 "dry_run": dry_run, 136 }, 137 ) 138 _raise_for_status(resp) 139 return PromotionResponse.model_validate(resp.json()) 140 141 def rename( 142 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 143 ) -> TeamDetail: 144 """Rename or re-describe a team on the organiser's authority. 145 146 For the team whose captain is unreachable, and for the name an 147 organiser has to change without asking. Empty strings are sent 148 as-is; omit a field to leave it alone. 149 """ 150 body: dict[str, str] = {} 151 if name: 152 body["name"] = name 153 if description: 154 body["description"] = description 155 resp = self._http.request( 156 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 157 ) 158 _raise_for_status(resp) 159 return TeamDetail.model_validate(resp.json()) 160 161 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 162 """Hand a team to a different one of its members. 163 164 The reason this resource exists. Every write a team makes about 165 itself is captain-only, so a captain who withdraws or loses their 166 account strands the whole squad — and the platform's only other 167 transfer happens when a captain leaves *voluntarily*, which is 168 exactly the case that does not apply. The target must already be 169 on the team. 170 """ 171 resp = self._http.request( 172 "POST", 173 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 174 json={"user_id": user_id}, 175 ) 176 _raise_for_status(resp) 177 return TeamDetail.model_validate(resp.json()) 178 179 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 180 """Drop a member from a team. 181 182 Goes through the same atomic op as the captain's kick, so the 183 behaviour is identical: the member holds **no** membership for 184 this competition afterwards — they are out of the event, not 185 moved to another team — and a team losing its last member is 186 collected. Removing every member is therefore also how a team is 187 dissolved. They re-enter through the normal Solo / Create / Join 188 wizard while registration is open. 189 """ 190 resp = self._http.request( 191 "DELETE", 192 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 193 ) 194 _raise_for_status(resp) 195 return TeamDetail.model_validate(resp.json()) 196 197 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 198 """Remove a team from the event. 199 200 ``reason`` is mandatory and must be at least 10 characters: it is 201 shown to the team when their next submission is refused, and it 202 is the record an appeal is argued from. Idempotent — calling it 203 again re-stamps the reason rather than failing. 204 """ 205 resp = self._http.request( 206 "POST", 207 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 208 json={"reason": reason}, 209 ) 210 _raise_for_status(resp) 211 return TeamStandingInfo.model_validate(resp.json()) 212 213 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 214 """Put a team back in the event. 215 216 The original reason is deliberately kept: a successful appeal 217 should not erase the record of the ruling it overturned. Only the 218 timestamp decides standing. A no-op on a team that is already in. 219 """ 220 resp = self._http.request( 221 "DELETE", 222 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 223 ) 224 _raise_for_status(resp) 225 return TeamStandingInfo.model_validate(resp.json()) 226 227 def list_bans( 228 self, competition_id: str, *, active_only: bool = False 229 ) -> builtins.list[CompetitionBanRow]: 230 """The event's ban ledger, newest first (#2068). Lifted rows are 231 kept and shown unless ``active_only``.""" 232 resp = self._http.request( 233 "GET", 234 f"/admin/competitions/{competition_id}/bans", 235 params={"active_only": "true" if active_only else "false"}, 236 ) 237 _raise_for_status(resp) 238 return [CompetitionBanRow.model_validate(row) for row in resp.json()] 239 240 def ban( 241 self, 242 competition_id: str, 243 *, 244 reason: str, 245 user_id: str = "", 246 team_id: str = "", 247 ) -> builtins.list[CompetitionBanRow]: 248 """Bar a person -- or every member of a team -- from this event. 249 250 Name exactly one of ``user_id`` / ``team_id``. A person is removed 251 from their team and refused at every registration door; a team is 252 disqualified and every member barred and removed (the team stays as 253 an empty, disqualified record, #2623). ``reason`` (>= 10 characters) 254 is shown to the person and is what an appeal is argued from. 255 Idempotent. 256 """ 257 resp = self._http.request( 258 "POST", 259 f"/admin/competitions/{competition_id}/bans", 260 json={ 261 "reason": reason, 262 "user_id": user_id, 263 "team_id": team_id, 264 }, 265 ) 266 _raise_for_status(resp) 267 return [CompetitionBanRow.model_validate(row) for row in resp.json()] 268 269 def lift_ban(self, competition_id: str, user_id: str) -> CompetitionBanRow: 270 """Let a person back in; they must still register. The row is kept.""" 271 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/bans/{user_id}") 272 _raise_for_status(resp) 273 return CompetitionBanRow.model_validate(resp.json()) 274 275 def solves(self, competition_id: str, team_id: str) -> builtins.list[TeamSolveRow]: 276 """Every solve of one team, live and voided, newest first (#2068).""" 277 resp = self._http.request( 278 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/solves" 279 ) 280 _raise_for_status(resp) 281 return [TeamSolveRow.model_validate(row) for row in resp.json()] 282 283 @staticmethod 284 def _refs(solves: builtins.list[tuple[str, str]]) -> builtins.list[dict[str, str]]: 285 return [{"challenge_id": c, "flag_id": f} for c, f in solves] 286 287 def preview_void( 288 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 289 ) -> VoidPreview: 290 """What voiding ``(challenge_id, flag_id)`` solves would do to the 291 board -- rank, score, first blood, other teams rescored -- without 292 doing it.""" 293 resp = self._http.request( 294 "POST", 295 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void-preview", 296 json={"solves": self._refs(solves)}, 297 ) 298 _raise_for_status(resp) 299 return VoidPreview.model_validate(resp.json()) 300 301 def void_solves( 302 self, 303 competition_id: str, 304 team_id: str, 305 solves: builtins.list[tuple[str, str]], 306 *, 307 reason: str, 308 ) -> VoidResult: 309 """Void solves as if they never happened: dynamic points rise back, 310 first and second blood pass to the next team, and the team cannot 311 re-earn them by submitting the same flag. ``reason`` (>= 10 312 characters) is what an appeal is argued from.""" 313 resp = self._http.request( 314 "POST", 315 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void", 316 json={"solves": self._refs(solves), "reason": reason}, 317 ) 318 _raise_for_status(resp) 319 return VoidResult.model_validate(resp.json()) 320 321 def restore_solves( 322 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 323 ) -> RestoreResult: 324 """Put voided solves back exactly as they were.""" 325 resp = self._http.request( 326 "POST", 327 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/restore", 328 json={"solves": self._refs(solves)}, 329 ) 330 _raise_for_status(resp) 331 return RestoreResult.model_validate(resp.json()) 332 333 def warn( 334 self, 335 competition_id: str, 336 team_id: str, 337 *, 338 reason: str, 339 case_id: str = "", 340 notify_team: bool = True, 341 ) -> RiskActionRow: 342 """Warn a team (#2068). Nothing else happens to it: it keeps playing and 343 stays on the board. ``reason`` (>= 10 characters) is shown to every 344 member and is what an appeal is argued from.""" 345 resp = self._http.request( 346 "POST", 347 f"/admin/competitions/{competition_id}/teams/{team_id}/warn", 348 json={"reason": reason, "case_id": case_id, "notify_team": notify_team}, 349 ) 350 _raise_for_status(resp) 351 return RiskActionRow.model_validate(resp.json())
40class AdminTeamsResource: 41 def __init__(self, http: BaseHttpClient) -> None: 42 self._http = http 43 44 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 45 """Every team in one competition, with its standing. 46 47 Deliberately not the review roster: that decrypts every member's 48 personal details and lands an audit row for the bulk read. 49 Managing a roster needs names and membership, not phone numbers. 50 """ 51 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 52 _raise_for_status(resp) 53 return [AdminTeamRow.model_validate(row) for row in resp.json()] 54 55 def add_member( 56 self, 57 competition_id: str, 58 *, 59 user_id: str = "", 60 email: str = "", 61 team_id: str = "", 62 team_name: str = "", 63 ) -> TeamDetail: 64 """Put a person into the competition directly — no invitation for 65 them to accept, no registration for them to fill in. 66 67 Name them by ``user_id`` or ``email`` (they must have an account). 68 ``team_id`` places them on that existing team; without it a new 69 team is created with them as captain, named ``team_name`` or the 70 solo default. One team per competition still holds (409 71 ``user_already_in_competition``), and so does the team-size cap 72 (409 ``team_full``). They are mailed that it happened. 73 """ 74 body: dict[str, str] = {} 75 if user_id: 76 body["user_id"] = user_id 77 if email: 78 body["email"] = email 79 if team_id: 80 body["team_id"] = team_id 81 if team_name: 82 body["team_name"] = team_name 83 resp = self._http.request( 84 "POST", f"/admin/competitions/{competition_id}/members", json=body 85 ) 86 _raise_for_status(resp) 87 return TeamDetail.model_validate(resp.json()) 88 89 def add_members( 90 self, competition_id: str, text: str, *, dry_run: bool = False 91 ) -> BatchAddMembersResponse: 92 """Add a list of people in one call (#2078) — partial success. 93 94 ``text`` is a pasted column or a CSV file's contents; the server 95 splits it, so every client agrees on the rules. Columns are 96 ``email, team, captain`` (a header row naming them is optional). 97 Rows sharing a team name form one team, the flagged captain (else 98 the first row) founding it. Each row gets its own coded 99 ``outcome``; an address with no account becomes an email 100 invitation (``invited_by_email``). ``dry_run=True`` classifies 101 without writing. Over 500 rows is a 422 ``too_many_rows``. 102 """ 103 resp = self._http.request( 104 "POST", 105 f"/admin/competitions/{competition_id}/members/batch", 106 json={"text": text, "dry_run": dry_run}, 107 ) 108 _raise_for_status(resp) 109 return BatchAddMembersResponse.model_validate(resp.json()) 110 111 def promote( 112 self, 113 competition_id: str, 114 source_competition_id: str, 115 top_n: int, 116 *, 117 exclude_team_ids: builtins.list[str] | None = None, 118 dry_run: bool = False, 119 ) -> PromotionResponse: 120 """Bring the top ``top_n`` teams of another competition's final board 121 into this one (#2120), with their members and captains. 122 123 Every team tied at the cutoff is listed and flagged 124 (``tied_at_cutoff``); ``exclude_team_ids`` leaves teams out, never 125 backfilled. ``dry_run=True`` is the preview. The caller must organise 126 both competitions (403 ``promotion_source_forbidden``); the import 127 waits for the source's end (409 ``promotion_source_not_over``). 128 """ 129 resp = self._http.request( 130 "POST", 131 f"/admin/competitions/{competition_id}/promotion", 132 json={ 133 "source_competition_id": source_competition_id, 134 "top_n": top_n, 135 "exclude_team_ids": list(exclude_team_ids or []), 136 "dry_run": dry_run, 137 }, 138 ) 139 _raise_for_status(resp) 140 return PromotionResponse.model_validate(resp.json()) 141 142 def rename( 143 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 144 ) -> TeamDetail: 145 """Rename or re-describe a team on the organiser's authority. 146 147 For the team whose captain is unreachable, and for the name an 148 organiser has to change without asking. Empty strings are sent 149 as-is; omit a field to leave it alone. 150 """ 151 body: dict[str, str] = {} 152 if name: 153 body["name"] = name 154 if description: 155 body["description"] = description 156 resp = self._http.request( 157 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 158 ) 159 _raise_for_status(resp) 160 return TeamDetail.model_validate(resp.json()) 161 162 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 163 """Hand a team to a different one of its members. 164 165 The reason this resource exists. Every write a team makes about 166 itself is captain-only, so a captain who withdraws or loses their 167 account strands the whole squad — and the platform's only other 168 transfer happens when a captain leaves *voluntarily*, which is 169 exactly the case that does not apply. The target must already be 170 on the team. 171 """ 172 resp = self._http.request( 173 "POST", 174 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 175 json={"user_id": user_id}, 176 ) 177 _raise_for_status(resp) 178 return TeamDetail.model_validate(resp.json()) 179 180 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 181 """Drop a member from a team. 182 183 Goes through the same atomic op as the captain's kick, so the 184 behaviour is identical: the member holds **no** membership for 185 this competition afterwards — they are out of the event, not 186 moved to another team — and a team losing its last member is 187 collected. Removing every member is therefore also how a team is 188 dissolved. They re-enter through the normal Solo / Create / Join 189 wizard while registration is open. 190 """ 191 resp = self._http.request( 192 "DELETE", 193 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 194 ) 195 _raise_for_status(resp) 196 return TeamDetail.model_validate(resp.json()) 197 198 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 199 """Remove a team from the event. 200 201 ``reason`` is mandatory and must be at least 10 characters: it is 202 shown to the team when their next submission is refused, and it 203 is the record an appeal is argued from. Idempotent — calling it 204 again re-stamps the reason rather than failing. 205 """ 206 resp = self._http.request( 207 "POST", 208 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 209 json={"reason": reason}, 210 ) 211 _raise_for_status(resp) 212 return TeamStandingInfo.model_validate(resp.json()) 213 214 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 215 """Put a team back in the event. 216 217 The original reason is deliberately kept: a successful appeal 218 should not erase the record of the ruling it overturned. Only the 219 timestamp decides standing. A no-op on a team that is already in. 220 """ 221 resp = self._http.request( 222 "DELETE", 223 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 224 ) 225 _raise_for_status(resp) 226 return TeamStandingInfo.model_validate(resp.json()) 227 228 def list_bans( 229 self, competition_id: str, *, active_only: bool = False 230 ) -> builtins.list[CompetitionBanRow]: 231 """The event's ban ledger, newest first (#2068). Lifted rows are 232 kept and shown unless ``active_only``.""" 233 resp = self._http.request( 234 "GET", 235 f"/admin/competitions/{competition_id}/bans", 236 params={"active_only": "true" if active_only else "false"}, 237 ) 238 _raise_for_status(resp) 239 return [CompetitionBanRow.model_validate(row) for row in resp.json()] 240 241 def ban( 242 self, 243 competition_id: str, 244 *, 245 reason: str, 246 user_id: str = "", 247 team_id: str = "", 248 ) -> builtins.list[CompetitionBanRow]: 249 """Bar a person -- or every member of a team -- from this event. 250 251 Name exactly one of ``user_id`` / ``team_id``. A person is removed 252 from their team and refused at every registration door; a team is 253 disqualified and every member barred and removed (the team stays as 254 an empty, disqualified record, #2623). ``reason`` (>= 10 characters) 255 is shown to the person and is what an appeal is argued from. 256 Idempotent. 257 """ 258 resp = self._http.request( 259 "POST", 260 f"/admin/competitions/{competition_id}/bans", 261 json={ 262 "reason": reason, 263 "user_id": user_id, 264 "team_id": team_id, 265 }, 266 ) 267 _raise_for_status(resp) 268 return [CompetitionBanRow.model_validate(row) for row in resp.json()] 269 270 def lift_ban(self, competition_id: str, user_id: str) -> CompetitionBanRow: 271 """Let a person back in; they must still register. The row is kept.""" 272 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/bans/{user_id}") 273 _raise_for_status(resp) 274 return CompetitionBanRow.model_validate(resp.json()) 275 276 def solves(self, competition_id: str, team_id: str) -> builtins.list[TeamSolveRow]: 277 """Every solve of one team, live and voided, newest first (#2068).""" 278 resp = self._http.request( 279 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/solves" 280 ) 281 _raise_for_status(resp) 282 return [TeamSolveRow.model_validate(row) for row in resp.json()] 283 284 @staticmethod 285 def _refs(solves: builtins.list[tuple[str, str]]) -> builtins.list[dict[str, str]]: 286 return [{"challenge_id": c, "flag_id": f} for c, f in solves] 287 288 def preview_void( 289 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 290 ) -> VoidPreview: 291 """What voiding ``(challenge_id, flag_id)`` solves would do to the 292 board -- rank, score, first blood, other teams rescored -- without 293 doing it.""" 294 resp = self._http.request( 295 "POST", 296 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void-preview", 297 json={"solves": self._refs(solves)}, 298 ) 299 _raise_for_status(resp) 300 return VoidPreview.model_validate(resp.json()) 301 302 def void_solves( 303 self, 304 competition_id: str, 305 team_id: str, 306 solves: builtins.list[tuple[str, str]], 307 *, 308 reason: str, 309 ) -> VoidResult: 310 """Void solves as if they never happened: dynamic points rise back, 311 first and second blood pass to the next team, and the team cannot 312 re-earn them by submitting the same flag. ``reason`` (>= 10 313 characters) is what an appeal is argued from.""" 314 resp = self._http.request( 315 "POST", 316 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void", 317 json={"solves": self._refs(solves), "reason": reason}, 318 ) 319 _raise_for_status(resp) 320 return VoidResult.model_validate(resp.json()) 321 322 def restore_solves( 323 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 324 ) -> RestoreResult: 325 """Put voided solves back exactly as they were.""" 326 resp = self._http.request( 327 "POST", 328 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/restore", 329 json={"solves": self._refs(solves)}, 330 ) 331 _raise_for_status(resp) 332 return RestoreResult.model_validate(resp.json()) 333 334 def warn( 335 self, 336 competition_id: str, 337 team_id: str, 338 *, 339 reason: str, 340 case_id: str = "", 341 notify_team: bool = True, 342 ) -> RiskActionRow: 343 """Warn a team (#2068). Nothing else happens to it: it keeps playing and 344 stays on the board. ``reason`` (>= 10 characters) is shown to every 345 member and is what an appeal is argued from.""" 346 resp = self._http.request( 347 "POST", 348 f"/admin/competitions/{competition_id}/teams/{team_id}/warn", 349 json={"reason": reason, "case_id": case_id, "notify_team": notify_team}, 350 ) 351 _raise_for_status(resp) 352 return RiskActionRow.model_validate(resp.json())
44 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 45 """Every team in one competition, with its standing. 46 47 Deliberately not the review roster: that decrypts every member's 48 personal details and lands an audit row for the bulk read. 49 Managing a roster needs names and membership, not phone numbers. 50 """ 51 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 52 _raise_for_status(resp) 53 return [AdminTeamRow.model_validate(row) for row in resp.json()]
Every team in one competition, with its standing.
Deliberately not the review roster: that decrypts every member's personal details and lands an audit row for the bulk read. Managing a roster needs names and membership, not phone numbers.
55 def add_member( 56 self, 57 competition_id: str, 58 *, 59 user_id: str = "", 60 email: str = "", 61 team_id: str = "", 62 team_name: str = "", 63 ) -> TeamDetail: 64 """Put a person into the competition directly — no invitation for 65 them to accept, no registration for them to fill in. 66 67 Name them by ``user_id`` or ``email`` (they must have an account). 68 ``team_id`` places them on that existing team; without it a new 69 team is created with them as captain, named ``team_name`` or the 70 solo default. One team per competition still holds (409 71 ``user_already_in_competition``), and so does the team-size cap 72 (409 ``team_full``). They are mailed that it happened. 73 """ 74 body: dict[str, str] = {} 75 if user_id: 76 body["user_id"] = user_id 77 if email: 78 body["email"] = email 79 if team_id: 80 body["team_id"] = team_id 81 if team_name: 82 body["team_name"] = team_name 83 resp = self._http.request( 84 "POST", f"/admin/competitions/{competition_id}/members", json=body 85 ) 86 _raise_for_status(resp) 87 return TeamDetail.model_validate(resp.json())
Put a person into the competition directly — no invitation for them to accept, no registration for them to fill in.
Name them by user_id or email (they must have an account).
team_id places them on that existing team; without it a new
team is created with them as captain, named team_name or the
solo default. One team per competition still holds (409
user_already_in_competition), and so does the team-size cap
(409 team_full). They are mailed that it happened.
89 def add_members( 90 self, competition_id: str, text: str, *, dry_run: bool = False 91 ) -> BatchAddMembersResponse: 92 """Add a list of people in one call (#2078) — partial success. 93 94 ``text`` is a pasted column or a CSV file's contents; the server 95 splits it, so every client agrees on the rules. Columns are 96 ``email, team, captain`` (a header row naming them is optional). 97 Rows sharing a team name form one team, the flagged captain (else 98 the first row) founding it. Each row gets its own coded 99 ``outcome``; an address with no account becomes an email 100 invitation (``invited_by_email``). ``dry_run=True`` classifies 101 without writing. Over 500 rows is a 422 ``too_many_rows``. 102 """ 103 resp = self._http.request( 104 "POST", 105 f"/admin/competitions/{competition_id}/members/batch", 106 json={"text": text, "dry_run": dry_run}, 107 ) 108 _raise_for_status(resp) 109 return BatchAddMembersResponse.model_validate(resp.json())
Add a list of people in one call (#2078) — partial success.
text is a pasted column or a CSV file's contents; the server
splits it, so every client agrees on the rules. Columns are
email, team, captain (a header row naming them is optional).
Rows sharing a team name form one team, the flagged captain (else
the first row) founding it. Each row gets its own coded
outcome; an address with no account becomes an email
invitation (invited_by_email). dry_run=True classifies
without writing. Over 500 rows is a 422 too_many_rows.
111 def promote( 112 self, 113 competition_id: str, 114 source_competition_id: str, 115 top_n: int, 116 *, 117 exclude_team_ids: builtins.list[str] | None = None, 118 dry_run: bool = False, 119 ) -> PromotionResponse: 120 """Bring the top ``top_n`` teams of another competition's final board 121 into this one (#2120), with their members and captains. 122 123 Every team tied at the cutoff is listed and flagged 124 (``tied_at_cutoff``); ``exclude_team_ids`` leaves teams out, never 125 backfilled. ``dry_run=True`` is the preview. The caller must organise 126 both competitions (403 ``promotion_source_forbidden``); the import 127 waits for the source's end (409 ``promotion_source_not_over``). 128 """ 129 resp = self._http.request( 130 "POST", 131 f"/admin/competitions/{competition_id}/promotion", 132 json={ 133 "source_competition_id": source_competition_id, 134 "top_n": top_n, 135 "exclude_team_ids": list(exclude_team_ids or []), 136 "dry_run": dry_run, 137 }, 138 ) 139 _raise_for_status(resp) 140 return PromotionResponse.model_validate(resp.json())
Bring the top top_n teams of another competition's final board
into this one (#2120), with their members and captains.
Every team tied at the cutoff is listed and flagged
(tied_at_cutoff); exclude_team_ids leaves teams out, never
backfilled. dry_run=True is the preview. The caller must organise
both competitions (403 promotion_source_forbidden); the import
waits for the source's end (409 promotion_source_not_over).
142 def rename( 143 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 144 ) -> TeamDetail: 145 """Rename or re-describe a team on the organiser's authority. 146 147 For the team whose captain is unreachable, and for the name an 148 organiser has to change without asking. Empty strings are sent 149 as-is; omit a field to leave it alone. 150 """ 151 body: dict[str, str] = {} 152 if name: 153 body["name"] = name 154 if description: 155 body["description"] = description 156 resp = self._http.request( 157 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 158 ) 159 _raise_for_status(resp) 160 return TeamDetail.model_validate(resp.json())
Rename or re-describe a team on the organiser's authority.
For the team whose captain is unreachable, and for the name an organiser has to change without asking. Empty strings are sent as-is; omit a field to leave it alone.
162 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 163 """Hand a team to a different one of its members. 164 165 The reason this resource exists. Every write a team makes about 166 itself is captain-only, so a captain who withdraws or loses their 167 account strands the whole squad — and the platform's only other 168 transfer happens when a captain leaves *voluntarily*, which is 169 exactly the case that does not apply. The target must already be 170 on the team. 171 """ 172 resp = self._http.request( 173 "POST", 174 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 175 json={"user_id": user_id}, 176 ) 177 _raise_for_status(resp) 178 return TeamDetail.model_validate(resp.json())
Hand a team to a different one of its members.
The reason this resource exists. Every write a team makes about itself is captain-only, so a captain who withdraws or loses their account strands the whole squad — and the platform's only other transfer happens when a captain leaves voluntarily, which is exactly the case that does not apply. The target must already be on the team.
180 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 181 """Drop a member from a team. 182 183 Goes through the same atomic op as the captain's kick, so the 184 behaviour is identical: the member holds **no** membership for 185 this competition afterwards — they are out of the event, not 186 moved to another team — and a team losing its last member is 187 collected. Removing every member is therefore also how a team is 188 dissolved. They re-enter through the normal Solo / Create / Join 189 wizard while registration is open. 190 """ 191 resp = self._http.request( 192 "DELETE", 193 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 194 ) 195 _raise_for_status(resp) 196 return TeamDetail.model_validate(resp.json())
Drop a member from a team.
Goes through the same atomic op as the captain's kick, so the behaviour is identical: the member holds no membership for this competition afterwards — they are out of the event, not moved to another team — and a team losing its last member is collected. Removing every member is therefore also how a team is dissolved. They re-enter through the normal Solo / Create / Join wizard while registration is open.
198 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 199 """Remove a team from the event. 200 201 ``reason`` is mandatory and must be at least 10 characters: it is 202 shown to the team when their next submission is refused, and it 203 is the record an appeal is argued from. Idempotent — calling it 204 again re-stamps the reason rather than failing. 205 """ 206 resp = self._http.request( 207 "POST", 208 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 209 json={"reason": reason}, 210 ) 211 _raise_for_status(resp) 212 return TeamStandingInfo.model_validate(resp.json())
Remove a team from the event.
reason is mandatory and must be at least 10 characters: it is
shown to the team when their next submission is refused, and it
is the record an appeal is argued from. Idempotent — calling it
again re-stamps the reason rather than failing.
214 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 215 """Put a team back in the event. 216 217 The original reason is deliberately kept: a successful appeal 218 should not erase the record of the ruling it overturned. Only the 219 timestamp decides standing. A no-op on a team that is already in. 220 """ 221 resp = self._http.request( 222 "DELETE", 223 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 224 ) 225 _raise_for_status(resp) 226 return TeamStandingInfo.model_validate(resp.json())
Put a team back in the event.
The original reason is deliberately kept: a successful appeal should not erase the record of the ruling it overturned. Only the timestamp decides standing. A no-op on a team that is already in.
228 def list_bans( 229 self, competition_id: str, *, active_only: bool = False 230 ) -> builtins.list[CompetitionBanRow]: 231 """The event's ban ledger, newest first (#2068). Lifted rows are 232 kept and shown unless ``active_only``.""" 233 resp = self._http.request( 234 "GET", 235 f"/admin/competitions/{competition_id}/bans", 236 params={"active_only": "true" if active_only else "false"}, 237 ) 238 _raise_for_status(resp) 239 return [CompetitionBanRow.model_validate(row) for row in resp.json()]
The event's ban ledger, newest first (#2068). Lifted rows are
kept and shown unless active_only.
241 def ban( 242 self, 243 competition_id: str, 244 *, 245 reason: str, 246 user_id: str = "", 247 team_id: str = "", 248 ) -> builtins.list[CompetitionBanRow]: 249 """Bar a person -- or every member of a team -- from this event. 250 251 Name exactly one of ``user_id`` / ``team_id``. A person is removed 252 from their team and refused at every registration door; a team is 253 disqualified and every member barred and removed (the team stays as 254 an empty, disqualified record, #2623). ``reason`` (>= 10 characters) 255 is shown to the person and is what an appeal is argued from. 256 Idempotent. 257 """ 258 resp = self._http.request( 259 "POST", 260 f"/admin/competitions/{competition_id}/bans", 261 json={ 262 "reason": reason, 263 "user_id": user_id, 264 "team_id": team_id, 265 }, 266 ) 267 _raise_for_status(resp) 268 return [CompetitionBanRow.model_validate(row) for row in resp.json()]
Bar a person -- or every member of a team -- from this event.
Name exactly one of user_id / team_id. A person is removed
from their team and refused at every registration door; a team is
disqualified and every member barred and removed (the team stays as
an empty, disqualified record, #2623). reason (>= 10 characters)
is shown to the person and is what an appeal is argued from.
Idempotent.
270 def lift_ban(self, competition_id: str, user_id: str) -> CompetitionBanRow: 271 """Let a person back in; they must still register. The row is kept.""" 272 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/bans/{user_id}") 273 _raise_for_status(resp) 274 return CompetitionBanRow.model_validate(resp.json())
Let a person back in; they must still register. The row is kept.
276 def solves(self, competition_id: str, team_id: str) -> builtins.list[TeamSolveRow]: 277 """Every solve of one team, live and voided, newest first (#2068).""" 278 resp = self._http.request( 279 "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/solves" 280 ) 281 _raise_for_status(resp) 282 return [TeamSolveRow.model_validate(row) for row in resp.json()]
Every solve of one team, live and voided, newest first (#2068).
288 def preview_void( 289 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 290 ) -> VoidPreview: 291 """What voiding ``(challenge_id, flag_id)`` solves would do to the 292 board -- rank, score, first blood, other teams rescored -- without 293 doing it.""" 294 resp = self._http.request( 295 "POST", 296 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void-preview", 297 json={"solves": self._refs(solves)}, 298 ) 299 _raise_for_status(resp) 300 return VoidPreview.model_validate(resp.json())
What voiding (challenge_id, flag_id) solves would do to the
board -- rank, score, first blood, other teams rescored -- without
doing it.
302 def void_solves( 303 self, 304 competition_id: str, 305 team_id: str, 306 solves: builtins.list[tuple[str, str]], 307 *, 308 reason: str, 309 ) -> VoidResult: 310 """Void solves as if they never happened: dynamic points rise back, 311 first and second blood pass to the next team, and the team cannot 312 re-earn them by submitting the same flag. ``reason`` (>= 10 313 characters) is what an appeal is argued from.""" 314 resp = self._http.request( 315 "POST", 316 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/void", 317 json={"solves": self._refs(solves), "reason": reason}, 318 ) 319 _raise_for_status(resp) 320 return VoidResult.model_validate(resp.json())
Void solves as if they never happened: dynamic points rise back,
first and second blood pass to the next team, and the team cannot
re-earn them by submitting the same flag. reason (>= 10
characters) is what an appeal is argued from.
322 def restore_solves( 323 self, competition_id: str, team_id: str, solves: builtins.list[tuple[str, str]] 324 ) -> RestoreResult: 325 """Put voided solves back exactly as they were.""" 326 resp = self._http.request( 327 "POST", 328 f"/admin/competitions/{competition_id}/teams/{team_id}/solves/restore", 329 json={"solves": self._refs(solves)}, 330 ) 331 _raise_for_status(resp) 332 return RestoreResult.model_validate(resp.json())
Put voided solves back exactly as they were.
334 def warn( 335 self, 336 competition_id: str, 337 team_id: str, 338 *, 339 reason: str, 340 case_id: str = "", 341 notify_team: bool = True, 342 ) -> RiskActionRow: 343 """Warn a team (#2068). Nothing else happens to it: it keeps playing and 344 stays on the board. ``reason`` (>= 10 characters) is shown to every 345 member and is what an appeal is argued from.""" 346 resp = self._http.request( 347 "POST", 348 f"/admin/competitions/{competition_id}/teams/{team_id}/warn", 349 json={"reason": reason, "case_id": case_id, "notify_team": notify_team}, 350 ) 351 _raise_for_status(resp) 352 return RiskActionRow.model_validate(resp.json())
Warn a team (#2068). Nothing else happens to it: it keeps playing and
stays on the board. reason (>= 10 characters) is shown to every
member and is what an appeal is argued from.