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 AdminTeamRow, TeamDetail, TeamStandingInfo 25 26 27class AdminTeamsResource: 28 def __init__(self, http: BaseHttpClient) -> None: 29 self._http = http 30 31 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 32 """Every team in one competition, with its standing. 33 34 Deliberately not the review roster: that decrypts every member's 35 personal details and lands an audit row for the bulk read. 36 Managing a roster needs names and membership, not phone numbers. 37 """ 38 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 39 _raise_for_status(resp) 40 return [AdminTeamRow.model_validate(row) for row in resp.json()] 41 42 def rename( 43 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 44 ) -> TeamDetail: 45 """Rename or re-describe a team on the organiser's authority. 46 47 For the team whose captain is unreachable, and for the name an 48 organiser has to change without asking. Empty strings are sent 49 as-is; omit a field to leave it alone. 50 """ 51 body: dict[str, str] = {} 52 if name: 53 body["name"] = name 54 if description: 55 body["description"] = description 56 resp = self._http.request( 57 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 58 ) 59 _raise_for_status(resp) 60 return TeamDetail.model_validate(resp.json()) 61 62 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 63 """Hand a team to a different one of its members. 64 65 The reason this resource exists. Every write a team makes about 66 itself is captain-only, so a captain who withdraws or loses their 67 account strands the whole squad — and the platform's only other 68 transfer happens when a captain leaves *voluntarily*, which is 69 exactly the case that does not apply. The target must already be 70 on the team. 71 """ 72 resp = self._http.request( 73 "POST", 74 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 75 json={"user_id": user_id}, 76 ) 77 _raise_for_status(resp) 78 return TeamDetail.model_validate(resp.json()) 79 80 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 81 """Drop a member from a team. 82 83 Goes through the same atomic op as the captain's kick, so the 84 behaviour is identical: the member holds **no** membership for 85 this competition afterwards — they are out of the event, not 86 moved to another team — and a team losing its last member is 87 collected. Removing every member is therefore also how a team is 88 dissolved. They re-enter through the normal Solo / Create / Join 89 wizard while registration is open. 90 """ 91 resp = self._http.request( 92 "DELETE", 93 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 94 ) 95 _raise_for_status(resp) 96 return TeamDetail.model_validate(resp.json()) 97 98 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 99 """Remove a team from the event. 100 101 ``reason`` is mandatory and must be at least 10 characters: it is 102 shown to the team when their next submission is refused, and it 103 is the record an appeal is argued from. Idempotent — calling it 104 again re-stamps the reason rather than failing. 105 """ 106 resp = self._http.request( 107 "POST", 108 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 109 json={"reason": reason}, 110 ) 111 _raise_for_status(resp) 112 return TeamStandingInfo.model_validate(resp.json()) 113 114 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 115 """Put a team back in the event. 116 117 The original reason is deliberately kept: a successful appeal 118 should not erase the record of the ruling it overturned. Only the 119 timestamp decides standing. A no-op on a team that is already in. 120 """ 121 resp = self._http.request( 122 "DELETE", 123 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 124 ) 125 _raise_for_status(resp) 126 return TeamStandingInfo.model_validate(resp.json())
28class AdminTeamsResource: 29 def __init__(self, http: BaseHttpClient) -> None: 30 self._http = http 31 32 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 33 """Every team in one competition, with its standing. 34 35 Deliberately not the review roster: that decrypts every member's 36 personal details and lands an audit row for the bulk read. 37 Managing a roster needs names and membership, not phone numbers. 38 """ 39 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 40 _raise_for_status(resp) 41 return [AdminTeamRow.model_validate(row) for row in resp.json()] 42 43 def rename( 44 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 45 ) -> TeamDetail: 46 """Rename or re-describe a team on the organiser's authority. 47 48 For the team whose captain is unreachable, and for the name an 49 organiser has to change without asking. Empty strings are sent 50 as-is; omit a field to leave it alone. 51 """ 52 body: dict[str, str] = {} 53 if name: 54 body["name"] = name 55 if description: 56 body["description"] = description 57 resp = self._http.request( 58 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 59 ) 60 _raise_for_status(resp) 61 return TeamDetail.model_validate(resp.json()) 62 63 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 64 """Hand a team to a different one of its members. 65 66 The reason this resource exists. Every write a team makes about 67 itself is captain-only, so a captain who withdraws or loses their 68 account strands the whole squad — and the platform's only other 69 transfer happens when a captain leaves *voluntarily*, which is 70 exactly the case that does not apply. The target must already be 71 on the team. 72 """ 73 resp = self._http.request( 74 "POST", 75 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 76 json={"user_id": user_id}, 77 ) 78 _raise_for_status(resp) 79 return TeamDetail.model_validate(resp.json()) 80 81 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 82 """Drop a member from a team. 83 84 Goes through the same atomic op as the captain's kick, so the 85 behaviour is identical: the member holds **no** membership for 86 this competition afterwards — they are out of the event, not 87 moved to another team — and a team losing its last member is 88 collected. Removing every member is therefore also how a team is 89 dissolved. They re-enter through the normal Solo / Create / Join 90 wizard while registration is open. 91 """ 92 resp = self._http.request( 93 "DELETE", 94 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 95 ) 96 _raise_for_status(resp) 97 return TeamDetail.model_validate(resp.json()) 98 99 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 100 """Remove a team from the event. 101 102 ``reason`` is mandatory and must be at least 10 characters: it is 103 shown to the team when their next submission is refused, and it 104 is the record an appeal is argued from. Idempotent — calling it 105 again re-stamps the reason rather than failing. 106 """ 107 resp = self._http.request( 108 "POST", 109 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 110 json={"reason": reason}, 111 ) 112 _raise_for_status(resp) 113 return TeamStandingInfo.model_validate(resp.json()) 114 115 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 116 """Put a team back in the event. 117 118 The original reason is deliberately kept: a successful appeal 119 should not erase the record of the ruling it overturned. Only the 120 timestamp decides standing. A no-op on a team that is already in. 121 """ 122 resp = self._http.request( 123 "DELETE", 124 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 125 ) 126 _raise_for_status(resp) 127 return TeamStandingInfo.model_validate(resp.json())
32 def list(self, competition_id: str) -> builtins.list[AdminTeamRow]: 33 """Every team in one competition, with its standing. 34 35 Deliberately not the review roster: that decrypts every member's 36 personal details and lands an audit row for the bulk read. 37 Managing a roster needs names and membership, not phone numbers. 38 """ 39 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/teams") 40 _raise_for_status(resp) 41 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.
43 def rename( 44 self, competition_id: str, team_id: str, *, name: str = "", description: str = "" 45 ) -> TeamDetail: 46 """Rename or re-describe a team on the organiser's authority. 47 48 For the team whose captain is unreachable, and for the name an 49 organiser has to change without asking. Empty strings are sent 50 as-is; omit a field to leave it alone. 51 """ 52 body: dict[str, str] = {} 53 if name: 54 body["name"] = name 55 if description: 56 body["description"] = description 57 resp = self._http.request( 58 "PATCH", f"/admin/competitions/{competition_id}/teams/{team_id}", json=body 59 ) 60 _raise_for_status(resp) 61 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.
63 def set_captain(self, competition_id: str, team_id: str, *, user_id: str) -> TeamDetail: 64 """Hand a team to a different one of its members. 65 66 The reason this resource exists. Every write a team makes about 67 itself is captain-only, so a captain who withdraws or loses their 68 account strands the whole squad — and the platform's only other 69 transfer happens when a captain leaves *voluntarily*, which is 70 exactly the case that does not apply. The target must already be 71 on the team. 72 """ 73 resp = self._http.request( 74 "POST", 75 f"/admin/competitions/{competition_id}/teams/{team_id}/captain", 76 json={"user_id": user_id}, 77 ) 78 _raise_for_status(resp) 79 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.
81 def remove_member(self, competition_id: str, team_id: str, user_id: str) -> TeamDetail: 82 """Drop a member from a team. 83 84 Goes through the same atomic op as the captain's kick, so the 85 behaviour is identical: the member holds **no** membership for 86 this competition afterwards — they are out of the event, not 87 moved to another team — and a team losing its last member is 88 collected. Removing every member is therefore also how a team is 89 dissolved. They re-enter through the normal Solo / Create / Join 90 wizard while registration is open. 91 """ 92 resp = self._http.request( 93 "DELETE", 94 f"/admin/competitions/{competition_id}/teams/{team_id}/members/{user_id}", 95 ) 96 _raise_for_status(resp) 97 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.
99 def disqualify(self, competition_id: str, team_id: str, *, reason: str) -> TeamStandingInfo: 100 """Remove a team from the event. 101 102 ``reason`` is mandatory and must be at least 10 characters: it is 103 shown to the team when their next submission is refused, and it 104 is the record an appeal is argued from. Idempotent — calling it 105 again re-stamps the reason rather than failing. 106 """ 107 resp = self._http.request( 108 "POST", 109 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 110 json={"reason": reason}, 111 ) 112 _raise_for_status(resp) 113 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.
115 def reinstate(self, competition_id: str, team_id: str) -> TeamStandingInfo: 116 """Put a team back in the event. 117 118 The original reason is deliberately kept: a successful appeal 119 should not erase the record of the ruling it overturned. Only the 120 timestamp decides standing. A no-op on a team that is already in. 121 """ 122 resp = self._http.request( 123 "DELETE", 124 f"/admin/competitions/{competition_id}/teams/{team_id}/disqualify", 125 ) 126 _raise_for_status(resp) 127 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.