ctfy.sdk.competition.team

client.competition(id)ctfy.sdk.competition.team — my team within one competition.

Captain + member management for the caller's team in a single competition: rename / leave / kick, plus the outgoing invite codes (team.invites) and the incoming join-request queue (team.requests).

  1"""``client.competition(id).team`` — my team within one competition.
  2
  3Captain + member management for the caller's team in a single competition:
  4rename / leave / kick, plus the outgoing invite codes (``team.invites``) and
  5the incoming join-request queue (``team.requests``).
  6"""
  7
  8from __future__ import annotations
  9
 10from functools import cached_property
 11
 12from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 13from ctfy.sdk.base import BaseHttpClient
 14from ctfy.server.models import (
 15    DirectInviteSent,
 16    RiskAppealView,
 17    RiskNotice,
 18    TeamAffiliationSelection,
 19    TeamDetail,
 20    TeamInviteInfo,
 21    TeamMachineLogin,
 22    TeamMachineStatus,
 23    TeamPracticeSummary,
 24)
 25
 26
 27class CompetitionTeam:
 28    """The caller's team in this competition."""
 29
 30    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 31        self._http = http
 32        self._cid = competition_id
 33
 34    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
 35        """Captain-only: rename / re-describe the team."""
 36        body: dict[str, str] = {}
 37        if name is not None:
 38            body["name"] = name
 39        if description is not None:
 40            body["description"] = description
 41        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
 42        _raise_for_status(resp)
 43        return TeamDetail.model_validate(resp.json())
 44
 45    def affiliation(self) -> TeamAffiliationSelection:
 46        """Captain-only: the squad's institution badge and its options.
 47
 48        Three facts in one answer — what the captain chose, what it
 49        resolves to *now*, and every institution a current member
 50        publishes. The middle one can be ``None`` while the first is
 51        set: that is a choice whose backing has gone, and it reads
 52        differently from having chosen nothing.
 53        """
 54        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
 55        _raise_for_status(resp)
 56        return TeamAffiliationSelection.model_validate(resp.json())
 57
 58    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
 59        """Captain-only: publish ``domain`` beside the squad's name.
 60
 61        Refused (``409 affiliation_not_published``) unless a current
 62        member already publishes that institution on their own profile —
 63        a team cannot claim one nobody on it has shown.
 64        """
 65        resp = self._http.request(
 66            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
 67        )
 68        _raise_for_status(resp)
 69        return TeamAffiliationSelection.model_validate(resp.json())
 70
 71    def clear_affiliation(self) -> TeamAffiliationSelection:
 72        """Captain-only: stop publishing a badge. Never refused, and
 73        takes no domain — so it also escapes the state where the choice
 74        outlived the member who backed it."""
 75        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
 76        _raise_for_status(resp)
 77        return TeamAffiliationSelection.model_validate(resp.json())
 78
 79    def machine(self) -> TeamMachineStatus:
 80        """Whether the team has a machine and whether *you* may open it.
 81
 82        Any member may ask; the answer carries no host and no username.
 83        """
 84        resp = self._http.request("GET", f"/competitions/{self._cid}/team/machine")
 85        _raise_for_status(resp)
 86        return TeamMachineStatus.model_validate(resp.json())
 87
 88    def practice(self) -> TeamPracticeSummary:
 89        """What your team answered right in practice here, after a challenge
 90        closed (#2183): ``challenge_count`` and each first right answer.
 91        Never a solve and never on a board; ``404 team_not_found`` without a
 92        team here."""
 93        resp = self._http.request("GET", f"/competitions/{self._cid}/my-team/practice")
 94        _raise_for_status(resp)
 95        return TeamPracticeSummary.model_validate(resp.json())
 96
 97    def view_machine(self) -> TeamMachineLogin:
 98        """Captain-only: open the team's machine login (``403 not_captain``
 99        for anyone else, ``404 machine_not_assigned`` when there is none).
100
101        Each call writes an audit row naming you. The login is a secret:
102        do not log or store the result.
103        """
104        resp = self._http.request("POST", f"/competitions/{self._cid}/team/machine/view")
105        _raise_for_status(resp)
106        return TeamMachineLogin.model_validate(resp.json())
107
108    def risk_notices(self) -> list[RiskNotice]:
109        """What the organisers did about your team here, newest first (#2068),
110        with whether you may appeal each. Empty for a team nothing was done to."""
111        resp = self._http.request("GET", f"/competitions/{self._cid}/risk/notices")
112        _raise_for_status(resp)
113        return [RiskNotice.model_validate(row) for row in resp.json()]
114
115    def appeal(self, action_id: str, body: str) -> RiskAppealView:
116        """Captain-only: appeal an action. One appeal per action, within seven
117        days of it or until the report locks (whichever is earlier). **Plain
118        text only**, at most 2000 characters: no Markdown, no HTML, no
119        attachments, and URLs are not made into links."""
120        resp = self._http.request(
121            "POST",
122            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal",
123            json={"body": body},
124        )
125        _raise_for_status(resp)
126        return RiskAppealView.model_validate(resp.json())
127
128    def supplement_appeal(self, action_id: str, body: str) -> RiskAppealView:
129        """Captain-only: add the one supplement to your appeal, on the same rules."""
130        resp = self._http.request(
131            "POST",
132            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal/supplement",
133            json={"body": body},
134        )
135        _raise_for_status(resp)
136        return RiskAppealView.model_validate(resp.json())
137
138    def leave(self) -> None:
139        """Drop your membership for this competition. If captain and others
140        remain, captaincy transfers to the longest-tenured member; if you
141        were the lone member the team is deleted."""
142        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
143        _raise_for_status(resp)
144
145    def kick(self, user_id: str) -> None:
146        """Captain-only: remove ``user_id`` from the team. Self-kick is
147        rejected (use :meth:`leave` instead)."""
148        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
149        _raise_for_status(resp)
150
151    @cached_property
152    def invites(self) -> CompetitionInvites:
153        """Invite codes + direct email invites the captain hands out."""
154        return CompetitionInvites(self._http, self._cid)
155
156    @cached_property
157    def requests(self) -> CompetitionRequests:
158        """Pending join requests the captain approves / rejects."""
159        return CompetitionRequests(self._http, self._cid)
160
161
162class CompetitionInvites:
163    """Captain-only: the team's outgoing invites for this competition."""
164
165    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
166        self._http = http
167        self._cid = competition_id
168
169    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
170        """Mint an invite code scoped to this comp. ``max_uses=0`` is
171        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
172        ``client.competition(id).register(mode="join", code=…)``."""
173        resp = self._http.request(
174            "POST",
175            f"/competitions/{self._cid}/invites",
176            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
177        )
178        _raise_for_status(resp)
179        return TeamInviteInfo.model_validate(resp.json())
180
181    def list(self) -> PagedList[TeamInviteInfo]:
182        """All outstanding invites for the caller's team in this comp."""
183        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
184        _raise_for_status(resp)
185        return _extract_items(resp.json(), TeamInviteInfo)
186
187    def revoke(self, invite_id: str) -> None:
188        """Invalidate a previously-minted invite."""
189        resp = self._http.request("DELETE", f"/competitions/{self._cid}/invites/{invite_id}")
190        _raise_for_status(resp)
191
192    def send(self, email: str) -> DirectInviteSent:
193        """Invite one exact email address. The lookup is case-insensitive
194        but otherwise unforgiving (no fuzzy match).
195
196        ⚠️ The answer is the same whether or not the address has an account,
197        is already on a team, or was already invited (#2863): it says the
198        invite was sent *if the address can receive one*. The recipient
199        sees it in their inbox and accepts via
200        :meth:`Competition.accept_invite`."""
201        resp = self._http.request(
202            "POST",
203            f"/competitions/{self._cid}/invites/direct",
204            json={"email": email},
205        )
206        _raise_for_status(resp)
207        return DirectInviteSent.model_validate(resp.json())
208
209
210class CompetitionRequests:
211    """Captain-only: incoming join requests for this competition."""
212
213    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
214        self._http = http
215        self._cid = competition_id
216
217    def approve(self, invite_id: str) -> TeamDetail:
218        """Approve a pending join request → the requester joins the team."""
219        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
220        _raise_for_status(resp)
221        return TeamDetail.model_validate(resp.json())
222
223    def reject(self, invite_id: str) -> None:
224        """Reject a pending join request → invite revoked. The requester
225        can re-open a fresh request afterwards."""
226        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/reject")
227        _raise_for_status(resp)
class CompetitionTeam:
 28class CompetitionTeam:
 29    """The caller's team in this competition."""
 30
 31    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 32        self._http = http
 33        self._cid = competition_id
 34
 35    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
 36        """Captain-only: rename / re-describe the team."""
 37        body: dict[str, str] = {}
 38        if name is not None:
 39            body["name"] = name
 40        if description is not None:
 41            body["description"] = description
 42        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
 43        _raise_for_status(resp)
 44        return TeamDetail.model_validate(resp.json())
 45
 46    def affiliation(self) -> TeamAffiliationSelection:
 47        """Captain-only: the squad's institution badge and its options.
 48
 49        Three facts in one answer — what the captain chose, what it
 50        resolves to *now*, and every institution a current member
 51        publishes. The middle one can be ``None`` while the first is
 52        set: that is a choice whose backing has gone, and it reads
 53        differently from having chosen nothing.
 54        """
 55        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
 56        _raise_for_status(resp)
 57        return TeamAffiliationSelection.model_validate(resp.json())
 58
 59    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
 60        """Captain-only: publish ``domain`` beside the squad's name.
 61
 62        Refused (``409 affiliation_not_published``) unless a current
 63        member already publishes that institution on their own profile —
 64        a team cannot claim one nobody on it has shown.
 65        """
 66        resp = self._http.request(
 67            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
 68        )
 69        _raise_for_status(resp)
 70        return TeamAffiliationSelection.model_validate(resp.json())
 71
 72    def clear_affiliation(self) -> TeamAffiliationSelection:
 73        """Captain-only: stop publishing a badge. Never refused, and
 74        takes no domain — so it also escapes the state where the choice
 75        outlived the member who backed it."""
 76        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
 77        _raise_for_status(resp)
 78        return TeamAffiliationSelection.model_validate(resp.json())
 79
 80    def machine(self) -> TeamMachineStatus:
 81        """Whether the team has a machine and whether *you* may open it.
 82
 83        Any member may ask; the answer carries no host and no username.
 84        """
 85        resp = self._http.request("GET", f"/competitions/{self._cid}/team/machine")
 86        _raise_for_status(resp)
 87        return TeamMachineStatus.model_validate(resp.json())
 88
 89    def practice(self) -> TeamPracticeSummary:
 90        """What your team answered right in practice here, after a challenge
 91        closed (#2183): ``challenge_count`` and each first right answer.
 92        Never a solve and never on a board; ``404 team_not_found`` without a
 93        team here."""
 94        resp = self._http.request("GET", f"/competitions/{self._cid}/my-team/practice")
 95        _raise_for_status(resp)
 96        return TeamPracticeSummary.model_validate(resp.json())
 97
 98    def view_machine(self) -> TeamMachineLogin:
 99        """Captain-only: open the team's machine login (``403 not_captain``
100        for anyone else, ``404 machine_not_assigned`` when there is none).
101
102        Each call writes an audit row naming you. The login is a secret:
103        do not log or store the result.
104        """
105        resp = self._http.request("POST", f"/competitions/{self._cid}/team/machine/view")
106        _raise_for_status(resp)
107        return TeamMachineLogin.model_validate(resp.json())
108
109    def risk_notices(self) -> list[RiskNotice]:
110        """What the organisers did about your team here, newest first (#2068),
111        with whether you may appeal each. Empty for a team nothing was done to."""
112        resp = self._http.request("GET", f"/competitions/{self._cid}/risk/notices")
113        _raise_for_status(resp)
114        return [RiskNotice.model_validate(row) for row in resp.json()]
115
116    def appeal(self, action_id: str, body: str) -> RiskAppealView:
117        """Captain-only: appeal an action. One appeal per action, within seven
118        days of it or until the report locks (whichever is earlier). **Plain
119        text only**, at most 2000 characters: no Markdown, no HTML, no
120        attachments, and URLs are not made into links."""
121        resp = self._http.request(
122            "POST",
123            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal",
124            json={"body": body},
125        )
126        _raise_for_status(resp)
127        return RiskAppealView.model_validate(resp.json())
128
129    def supplement_appeal(self, action_id: str, body: str) -> RiskAppealView:
130        """Captain-only: add the one supplement to your appeal, on the same rules."""
131        resp = self._http.request(
132            "POST",
133            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal/supplement",
134            json={"body": body},
135        )
136        _raise_for_status(resp)
137        return RiskAppealView.model_validate(resp.json())
138
139    def leave(self) -> None:
140        """Drop your membership for this competition. If captain and others
141        remain, captaincy transfers to the longest-tenured member; if you
142        were the lone member the team is deleted."""
143        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
144        _raise_for_status(resp)
145
146    def kick(self, user_id: str) -> None:
147        """Captain-only: remove ``user_id`` from the team. Self-kick is
148        rejected (use :meth:`leave` instead)."""
149        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
150        _raise_for_status(resp)
151
152    @cached_property
153    def invites(self) -> CompetitionInvites:
154        """Invite codes + direct email invites the captain hands out."""
155        return CompetitionInvites(self._http, self._cid)
156
157    @cached_property
158    def requests(self) -> CompetitionRequests:
159        """Pending join requests the captain approves / rejects."""
160        return CompetitionRequests(self._http, self._cid)

The caller's team in this competition.

CompetitionTeam(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
31    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
32        self._http = http
33        self._cid = competition_id
def rename( self, *, name: str | None = None, description: str | None = None) -> ctfy.server.models.TeamDetail:
35    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
36        """Captain-only: rename / re-describe the team."""
37        body: dict[str, str] = {}
38        if name is not None:
39            body["name"] = name
40        if description is not None:
41            body["description"] = description
42        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
43        _raise_for_status(resp)
44        return TeamDetail.model_validate(resp.json())

Captain-only: rename / re-describe the team.

def affiliation(self) -> ctfy.server.models.TeamAffiliationSelection:
46    def affiliation(self) -> TeamAffiliationSelection:
47        """Captain-only: the squad's institution badge and its options.
48
49        Three facts in one answer — what the captain chose, what it
50        resolves to *now*, and every institution a current member
51        publishes. The middle one can be ``None`` while the first is
52        set: that is a choice whose backing has gone, and it reads
53        differently from having chosen nothing.
54        """
55        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
56        _raise_for_status(resp)
57        return TeamAffiliationSelection.model_validate(resp.json())

Captain-only: the squad's institution badge and its options.

Three facts in one answer — what the captain chose, what it resolves to now, and every institution a current member publishes. The middle one can be None while the first is set: that is a choice whose backing has gone, and it reads differently from having chosen nothing.

def set_affiliation(self, domain: str) -> ctfy.server.models.TeamAffiliationSelection:
59    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
60        """Captain-only: publish ``domain`` beside the squad's name.
61
62        Refused (``409 affiliation_not_published``) unless a current
63        member already publishes that institution on their own profile —
64        a team cannot claim one nobody on it has shown.
65        """
66        resp = self._http.request(
67            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
68        )
69        _raise_for_status(resp)
70        return TeamAffiliationSelection.model_validate(resp.json())

Captain-only: publish domain beside the squad's name.

Refused (409 affiliation_not_published) unless a current member already publishes that institution on their own profile — a team cannot claim one nobody on it has shown.

def clear_affiliation(self) -> ctfy.server.models.TeamAffiliationSelection:
72    def clear_affiliation(self) -> TeamAffiliationSelection:
73        """Captain-only: stop publishing a badge. Never refused, and
74        takes no domain — so it also escapes the state where the choice
75        outlived the member who backed it."""
76        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
77        _raise_for_status(resp)
78        return TeamAffiliationSelection.model_validate(resp.json())

Captain-only: stop publishing a badge. Never refused, and takes no domain — so it also escapes the state where the choice outlived the member who backed it.

def machine(self) -> ctfy.server.models.TeamMachineStatus:
80    def machine(self) -> TeamMachineStatus:
81        """Whether the team has a machine and whether *you* may open it.
82
83        Any member may ask; the answer carries no host and no username.
84        """
85        resp = self._http.request("GET", f"/competitions/{self._cid}/team/machine")
86        _raise_for_status(resp)
87        return TeamMachineStatus.model_validate(resp.json())

Whether the team has a machine and whether you may open it.

Any member may ask; the answer carries no host and no username.

def practice(self) -> ctfy.server.models.TeamPracticeSummary:
89    def practice(self) -> TeamPracticeSummary:
90        """What your team answered right in practice here, after a challenge
91        closed (#2183): ``challenge_count`` and each first right answer.
92        Never a solve and never on a board; ``404 team_not_found`` without a
93        team here."""
94        resp = self._http.request("GET", f"/competitions/{self._cid}/my-team/practice")
95        _raise_for_status(resp)
96        return TeamPracticeSummary.model_validate(resp.json())

What your team answered right in practice here, after a challenge closed (#2183): challenge_count and each first right answer. Never a solve and never on a board; 404 team_not_found without a team here.

def view_machine(self) -> ctfy.server.models.TeamMachineLogin:
 98    def view_machine(self) -> TeamMachineLogin:
 99        """Captain-only: open the team's machine login (``403 not_captain``
100        for anyone else, ``404 machine_not_assigned`` when there is none).
101
102        Each call writes an audit row naming you. The login is a secret:
103        do not log or store the result.
104        """
105        resp = self._http.request("POST", f"/competitions/{self._cid}/team/machine/view")
106        _raise_for_status(resp)
107        return TeamMachineLogin.model_validate(resp.json())

Captain-only: open the team's machine login (403 not_captain for anyone else, 404 machine_not_assigned when there is none).

Each call writes an audit row naming you. The login is a secret: do not log or store the result.

def risk_notices(self) -> list[ctfy.server.models.RiskNotice]:
109    def risk_notices(self) -> list[RiskNotice]:
110        """What the organisers did about your team here, newest first (#2068),
111        with whether you may appeal each. Empty for a team nothing was done to."""
112        resp = self._http.request("GET", f"/competitions/{self._cid}/risk/notices")
113        _raise_for_status(resp)
114        return [RiskNotice.model_validate(row) for row in resp.json()]

What the organisers did about your team here, newest first (#2068), with whether you may appeal each. Empty for a team nothing was done to.

def appeal( self, action_id: str, body: str) -> ctfy.server.models.RiskAppealView:
116    def appeal(self, action_id: str, body: str) -> RiskAppealView:
117        """Captain-only: appeal an action. One appeal per action, within seven
118        days of it or until the report locks (whichever is earlier). **Plain
119        text only**, at most 2000 characters: no Markdown, no HTML, no
120        attachments, and URLs are not made into links."""
121        resp = self._http.request(
122            "POST",
123            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal",
124            json={"body": body},
125        )
126        _raise_for_status(resp)
127        return RiskAppealView.model_validate(resp.json())

Captain-only: appeal an action. One appeal per action, within seven days of it or until the report locks (whichever is earlier). Plain text only, at most 2000 characters: no Markdown, no HTML, no attachments, and URLs are not made into links.

def supplement_appeal( self, action_id: str, body: str) -> ctfy.server.models.RiskAppealView:
129    def supplement_appeal(self, action_id: str, body: str) -> RiskAppealView:
130        """Captain-only: add the one supplement to your appeal, on the same rules."""
131        resp = self._http.request(
132            "POST",
133            f"/competitions/{self._cid}/risk/actions/{action_id}/appeal/supplement",
134            json={"body": body},
135        )
136        _raise_for_status(resp)
137        return RiskAppealView.model_validate(resp.json())

Captain-only: add the one supplement to your appeal, on the same rules.

def leave(self) -> None:
139    def leave(self) -> None:
140        """Drop your membership for this competition. If captain and others
141        remain, captaincy transfers to the longest-tenured member; if you
142        were the lone member the team is deleted."""
143        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
144        _raise_for_status(resp)

Drop your membership for this competition. If captain and others remain, captaincy transfers to the longest-tenured member; if you were the lone member the team is deleted.

def kick(self, user_id: str) -> None:
146    def kick(self, user_id: str) -> None:
147        """Captain-only: remove ``user_id`` from the team. Self-kick is
148        rejected (use :meth:`leave` instead)."""
149        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
150        _raise_for_status(resp)

Captain-only: remove user_id from the team. Self-kick is rejected (use leave() instead).

invites: CompetitionInvites
152    @cached_property
153    def invites(self) -> CompetitionInvites:
154        """Invite codes + direct email invites the captain hands out."""
155        return CompetitionInvites(self._http, self._cid)

Invite codes + direct email invites the captain hands out.

requests: CompetitionRequests
157    @cached_property
158    def requests(self) -> CompetitionRequests:
159        """Pending join requests the captain approves / rejects."""
160        return CompetitionRequests(self._http, self._cid)

Pending join requests the captain approves / rejects.

class CompetitionInvites:
163class CompetitionInvites:
164    """Captain-only: the team's outgoing invites for this competition."""
165
166    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
167        self._http = http
168        self._cid = competition_id
169
170    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
171        """Mint an invite code scoped to this comp. ``max_uses=0`` is
172        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
173        ``client.competition(id).register(mode="join", code=…)``."""
174        resp = self._http.request(
175            "POST",
176            f"/competitions/{self._cid}/invites",
177            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
178        )
179        _raise_for_status(resp)
180        return TeamInviteInfo.model_validate(resp.json())
181
182    def list(self) -> PagedList[TeamInviteInfo]:
183        """All outstanding invites for the caller's team in this comp."""
184        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
185        _raise_for_status(resp)
186        return _extract_items(resp.json(), TeamInviteInfo)
187
188    def revoke(self, invite_id: str) -> None:
189        """Invalidate a previously-minted invite."""
190        resp = self._http.request("DELETE", f"/competitions/{self._cid}/invites/{invite_id}")
191        _raise_for_status(resp)
192
193    def send(self, email: str) -> DirectInviteSent:
194        """Invite one exact email address. The lookup is case-insensitive
195        but otherwise unforgiving (no fuzzy match).
196
197        ⚠️ The answer is the same whether or not the address has an account,
198        is already on a team, or was already invited (#2863): it says the
199        invite was sent *if the address can receive one*. The recipient
200        sees it in their inbox and accepts via
201        :meth:`Competition.accept_invite`."""
202        resp = self._http.request(
203            "POST",
204            f"/competitions/{self._cid}/invites/direct",
205            json={"email": email},
206        )
207        _raise_for_status(resp)
208        return DirectInviteSent.model_validate(resp.json())

Captain-only: the team's outgoing invites for this competition.

CompetitionInvites(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
166    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
167        self._http = http
168        self._cid = competition_id
def create( self, *, max_uses: int = 1, ttl_seconds: int = 86400) -> ctfy.server.models.TeamInviteInfo:
170    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
171        """Mint an invite code scoped to this comp. ``max_uses=0`` is
172        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
173        ``client.competition(id).register(mode="join", code=…)``."""
174        resp = self._http.request(
175            "POST",
176            f"/competitions/{self._cid}/invites",
177            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
178        )
179        _raise_for_status(resp)
180        return TeamInviteInfo.model_validate(resp.json())

Mint an invite code scoped to this comp. max_uses=0 is unlimited; ttl_seconds=0 never expires. Others redeem it via client.competition(id).register(mode="join", code=…).

def list( self) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.TeamInviteInfo]:
182    def list(self) -> PagedList[TeamInviteInfo]:
183        """All outstanding invites for the caller's team in this comp."""
184        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
185        _raise_for_status(resp)
186        return _extract_items(resp.json(), TeamInviteInfo)

All outstanding invites for the caller's team in this comp.

def revoke(self, invite_id: str) -> None:
188    def revoke(self, invite_id: str) -> None:
189        """Invalidate a previously-minted invite."""
190        resp = self._http.request("DELETE", f"/competitions/{self._cid}/invites/{invite_id}")
191        _raise_for_status(resp)

Invalidate a previously-minted invite.

def send(self, email: str) -> ctfy.server.models.DirectInviteSent:
193    def send(self, email: str) -> DirectInviteSent:
194        """Invite one exact email address. The lookup is case-insensitive
195        but otherwise unforgiving (no fuzzy match).
196
197        ⚠️ The answer is the same whether or not the address has an account,
198        is already on a team, or was already invited (#2863): it says the
199        invite was sent *if the address can receive one*. The recipient
200        sees it in their inbox and accepts via
201        :meth:`Competition.accept_invite`."""
202        resp = self._http.request(
203            "POST",
204            f"/competitions/{self._cid}/invites/direct",
205            json={"email": email},
206        )
207        _raise_for_status(resp)
208        return DirectInviteSent.model_validate(resp.json())

Invite one exact email address. The lookup is case-insensitive but otherwise unforgiving (no fuzzy match).

⚠️ The answer is the same whether or not the address has an account, is already on a team, or was already invited (#2863): it says the invite was sent if the address can receive one. The recipient sees it in their inbox and accepts via Competition.accept_invite().

class CompetitionRequests:
211class CompetitionRequests:
212    """Captain-only: incoming join requests for this competition."""
213
214    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
215        self._http = http
216        self._cid = competition_id
217
218    def approve(self, invite_id: str) -> TeamDetail:
219        """Approve a pending join request → the requester joins the team."""
220        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
221        _raise_for_status(resp)
222        return TeamDetail.model_validate(resp.json())
223
224    def reject(self, invite_id: str) -> None:
225        """Reject a pending join request → invite revoked. The requester
226        can re-open a fresh request afterwards."""
227        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/reject")
228        _raise_for_status(resp)

Captain-only: incoming join requests for this competition.

CompetitionRequests(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
214    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
215        self._http = http
216        self._cid = competition_id
def approve(self, invite_id: str) -> ctfy.server.models.TeamDetail:
218    def approve(self, invite_id: str) -> TeamDetail:
219        """Approve a pending join request → the requester joins the team."""
220        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
221        _raise_for_status(resp)
222        return TeamDetail.model_validate(resp.json())

Approve a pending join request → the requester joins the team.

def reject(self, invite_id: str) -> None:
224    def reject(self, invite_id: str) -> None:
225        """Reject a pending join request → invite revoked. The requester
226        can re-open a fresh request afterwards."""
227        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/reject")
228        _raise_for_status(resp)

Reject a pending join request → invite revoked. The requester can re-open a fresh request afterwards.