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())
class AdminTeamsResource:
 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())
AdminTeamsResource(http: ctfy.sdk.base.BaseHttpClient)
41    def __init__(self, http: BaseHttpClient) -> None:
42        self._http = http
def list(self, competition_id: str) -> list[ctfy.server.models.AdminTeamRow]:
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.

def add_member( self, competition_id: str, *, user_id: str = '', email: str = '', team_id: str = '', team_name: str = '') -> ctfy.server.models.TeamDetail:
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.

def add_members( self, competition_id: str, text: str, *, dry_run: bool = False) -> ctfy.server.models.BatchAddMembersResponse:
 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.

def promote( self, competition_id: str, source_competition_id: str, top_n: int, *, exclude_team_ids: list[str] | None = None, dry_run: bool = False) -> ctfy.server.models.PromotionResponse:
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).

def rename( self, competition_id: str, team_id: str, *, name: str = '', description: str = '') -> ctfy.server.models.TeamDetail:
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.

def set_captain( self, competition_id: str, team_id: str, *, user_id: str) -> ctfy.server.models.TeamDetail:
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.

def remove_member( self, competition_id: str, team_id: str, user_id: str) -> ctfy.server.models.TeamDetail:
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.

def disqualify( self, competition_id: str, team_id: str, *, reason: str) -> ctfy.server.models.TeamStandingInfo:
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.

def reinstate( self, competition_id: str, team_id: str) -> ctfy.server.models.TeamStandingInfo:
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.

def list_bans( self, competition_id: str, *, active_only: bool = False) -> list[ctfy.server.models.CompetitionBanRow]:
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.

def ban( self, competition_id: str, *, reason: str, user_id: str = '', team_id: str = '') -> list[ctfy.server.models.CompetitionBanRow]:
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.

def lift_ban( self, competition_id: str, user_id: str) -> ctfy.server.models.CompetitionBanRow:
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.

def solves( self, competition_id: str, team_id: str) -> list[ctfy.server.models.TeamSolveRow]:
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).

def preview_void( self, competition_id: str, team_id: str, solves: list[tuple[str, str]]) -> ctfy.server.models.VoidPreview:
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.

def void_solves( self, competition_id: str, team_id: str, solves: list[tuple[str, str]], *, reason: str) -> ctfy.server.models.VoidResult:
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.

def restore_solves( self, competition_id: str, team_id: str, solves: list[tuple[str, str]]) -> ctfy.server.models.RestoreResult:
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.

def warn( self, competition_id: str, team_id: str, *, reason: str, case_id: str = '', notify_team: bool = True) -> ctfy.server.models.RiskActionRow:
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.