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

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

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

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

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

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