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 TeamAffiliationSelection, TeamDetail, TeamInviteInfo
 15
 16
 17class CompetitionTeam:
 18    """The caller's team in this competition."""
 19
 20    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 21        self._http = http
 22        self._cid = competition_id
 23
 24    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
 25        """Captain-only: rename / re-describe the team."""
 26        body: dict[str, str] = {}
 27        if name is not None:
 28            body["name"] = name
 29        if description is not None:
 30            body["description"] = description
 31        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
 32        _raise_for_status(resp)
 33        return TeamDetail.model_validate(resp.json())
 34
 35    def affiliation(self) -> TeamAffiliationSelection:
 36        """Captain-only: the squad's institution badge and its options.
 37
 38        Three facts in one answer — what the captain chose, what it
 39        resolves to *now*, and every institution a current member
 40        publishes. The middle one can be ``None`` while the first is
 41        set: that is a choice whose backing has gone, and it reads
 42        differently from having chosen nothing.
 43        """
 44        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
 45        _raise_for_status(resp)
 46        return TeamAffiliationSelection.model_validate(resp.json())
 47
 48    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
 49        """Captain-only: publish ``domain`` beside the squad's name.
 50
 51        Refused (``409 affiliation_not_published``) unless a current
 52        member already publishes that institution on their own profile —
 53        a team cannot claim one nobody on it has shown.
 54        """
 55        resp = self._http.request(
 56            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
 57        )
 58        _raise_for_status(resp)
 59        return TeamAffiliationSelection.model_validate(resp.json())
 60
 61    def clear_affiliation(self) -> TeamAffiliationSelection:
 62        """Captain-only: stop publishing a badge. Never refused, and
 63        takes no domain — so it also escapes the state where the choice
 64        outlived the member who backed it."""
 65        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
 66        _raise_for_status(resp)
 67        return TeamAffiliationSelection.model_validate(resp.json())
 68
 69    def leave(self) -> None:
 70        """Drop your membership for this competition. If captain and others
 71        remain, captaincy transfers to the longest-tenured member; if you
 72        were the lone member the team is deleted."""
 73        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
 74        _raise_for_status(resp)
 75
 76    def kick(self, user_id: str) -> None:
 77        """Captain-only: remove ``user_id`` from the team. Self-kick is
 78        rejected (use :meth:`leave` instead)."""
 79        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
 80        _raise_for_status(resp)
 81
 82    @cached_property
 83    def invites(self) -> CompetitionInvites:
 84        """Invite codes + direct email invites the captain hands out."""
 85        return CompetitionInvites(self._http, self._cid)
 86
 87    @cached_property
 88    def requests(self) -> CompetitionRequests:
 89        """Pending join requests the captain approves / rejects."""
 90        return CompetitionRequests(self._http, self._cid)
 91
 92
 93class CompetitionInvites:
 94    """Captain-only: the team's outgoing invites for this competition."""
 95
 96    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 97        self._http = http
 98        self._cid = competition_id
 99
100    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
101        """Mint an invite code scoped to this comp. ``max_uses=0`` is
102        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
103        ``client.competition(id).register(mode="join", code=…)``."""
104        resp = self._http.request(
105            "POST",
106            f"/competitions/{self._cid}/invites",
107            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
108        )
109        _raise_for_status(resp)
110        return TeamInviteInfo.model_validate(resp.json())
111
112    def list(self) -> PagedList[TeamInviteInfo]:
113        """All outstanding invites for the caller's team in this comp."""
114        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
115        _raise_for_status(resp)
116        return _extract_items(resp.json(), TeamInviteInfo)
117
118    def revoke(self, invite_id: str) -> None:
119        """Invalidate a previously-minted invite."""
120        resp = self._http.request("DELETE", f"/competitions/{self._cid}/invites/{invite_id}")
121        _raise_for_status(resp)
122
123    def send(self, email: str) -> TeamInviteInfo:
124        """Mint an invite addressed to one exact email. The lookup is
125        case-insensitive but otherwise unforgiving (no fuzzy match, no
126        enumeration vectors). The recipient accepts via
127        :meth:`Competition.accept_invite`."""
128        resp = self._http.request(
129            "POST",
130            f"/competitions/{self._cid}/invites/direct",
131            json={"email": email},
132        )
133        _raise_for_status(resp)
134        return TeamInviteInfo.model_validate(resp.json())
135
136
137class CompetitionRequests:
138    """Captain-only: incoming join requests for this competition."""
139
140    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
141        self._http = http
142        self._cid = competition_id
143
144    def approve(self, invite_id: str) -> TeamDetail:
145        """Approve a pending join request → the requester joins the team."""
146        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
147        _raise_for_status(resp)
148        return TeamDetail.model_validate(resp.json())
149
150    def reject(self, invite_id: str) -> None:
151        """Reject a pending join request → invite revoked. The requester
152        can re-open a fresh request afterwards."""
153        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/reject")
154        _raise_for_status(resp)
class CompetitionTeam:
18class CompetitionTeam:
19    """The caller's team in this competition."""
20
21    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
22        self._http = http
23        self._cid = competition_id
24
25    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
26        """Captain-only: rename / re-describe the team."""
27        body: dict[str, str] = {}
28        if name is not None:
29            body["name"] = name
30        if description is not None:
31            body["description"] = description
32        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
33        _raise_for_status(resp)
34        return TeamDetail.model_validate(resp.json())
35
36    def affiliation(self) -> TeamAffiliationSelection:
37        """Captain-only: the squad's institution badge and its options.
38
39        Three facts in one answer — what the captain chose, what it
40        resolves to *now*, and every institution a current member
41        publishes. The middle one can be ``None`` while the first is
42        set: that is a choice whose backing has gone, and it reads
43        differently from having chosen nothing.
44        """
45        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
46        _raise_for_status(resp)
47        return TeamAffiliationSelection.model_validate(resp.json())
48
49    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
50        """Captain-only: publish ``domain`` beside the squad's name.
51
52        Refused (``409 affiliation_not_published``) unless a current
53        member already publishes that institution on their own profile —
54        a team cannot claim one nobody on it has shown.
55        """
56        resp = self._http.request(
57            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
58        )
59        _raise_for_status(resp)
60        return TeamAffiliationSelection.model_validate(resp.json())
61
62    def clear_affiliation(self) -> TeamAffiliationSelection:
63        """Captain-only: stop publishing a badge. Never refused, and
64        takes no domain — so it also escapes the state where the choice
65        outlived the member who backed it."""
66        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
67        _raise_for_status(resp)
68        return TeamAffiliationSelection.model_validate(resp.json())
69
70    def leave(self) -> None:
71        """Drop your membership for this competition. If captain and others
72        remain, captaincy transfers to the longest-tenured member; if you
73        were the lone member the team is deleted."""
74        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
75        _raise_for_status(resp)
76
77    def kick(self, user_id: str) -> None:
78        """Captain-only: remove ``user_id`` from the team. Self-kick is
79        rejected (use :meth:`leave` instead)."""
80        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
81        _raise_for_status(resp)
82
83    @cached_property
84    def invites(self) -> CompetitionInvites:
85        """Invite codes + direct email invites the captain hands out."""
86        return CompetitionInvites(self._http, self._cid)
87
88    @cached_property
89    def requests(self) -> CompetitionRequests:
90        """Pending join requests the captain approves / rejects."""
91        return CompetitionRequests(self._http, self._cid)

The caller's team in this competition.

CompetitionTeam(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
21    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
22        self._http = http
23        self._cid = competition_id
def rename( self, *, name: str | None = None, description: str | None = None) -> ctfy.server.models.TeamDetail:
25    def rename(self, *, name: str | None = None, description: str | None = None) -> TeamDetail:
26        """Captain-only: rename / re-describe the team."""
27        body: dict[str, str] = {}
28        if name is not None:
29            body["name"] = name
30        if description is not None:
31            body["description"] = description
32        resp = self._http.request("PATCH", f"/competitions/{self._cid}/team", json=body)
33        _raise_for_status(resp)
34        return TeamDetail.model_validate(resp.json())

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

def affiliation(self) -> ctfy.server.models.TeamAffiliationSelection:
36    def affiliation(self) -> TeamAffiliationSelection:
37        """Captain-only: the squad's institution badge and its options.
38
39        Three facts in one answer — what the captain chose, what it
40        resolves to *now*, and every institution a current member
41        publishes. The middle one can be ``None`` while the first is
42        set: that is a choice whose backing has gone, and it reads
43        differently from having chosen nothing.
44        """
45        resp = self._http.request("GET", f"/competitions/{self._cid}/team/affiliation")
46        _raise_for_status(resp)
47        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:
49    def set_affiliation(self, domain: str) -> TeamAffiliationSelection:
50        """Captain-only: publish ``domain`` beside the squad's name.
51
52        Refused (``409 affiliation_not_published``) unless a current
53        member already publishes that institution on their own profile —
54        a team cannot claim one nobody on it has shown.
55        """
56        resp = self._http.request(
57            "PUT", f"/competitions/{self._cid}/team/affiliation", json={"domain": domain}
58        )
59        _raise_for_status(resp)
60        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:
62    def clear_affiliation(self) -> TeamAffiliationSelection:
63        """Captain-only: stop publishing a badge. Never refused, and
64        takes no domain — so it also escapes the state where the choice
65        outlived the member who backed it."""
66        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/affiliation")
67        _raise_for_status(resp)
68        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 leave(self) -> None:
70    def leave(self) -> None:
71        """Drop your membership for this competition. If captain and others
72        remain, captaincy transfers to the longest-tenured member; if you
73        were the lone member the team is deleted."""
74        resp = self._http.request("POST", f"/competitions/{self._cid}/team/leave")
75        _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:
77    def kick(self, user_id: str) -> None:
78        """Captain-only: remove ``user_id`` from the team. Self-kick is
79        rejected (use :meth:`leave` instead)."""
80        resp = self._http.request("DELETE", f"/competitions/{self._cid}/team/members/{user_id}")
81        _raise_for_status(resp)

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

invites: CompetitionInvites
83    @cached_property
84    def invites(self) -> CompetitionInvites:
85        """Invite codes + direct email invites the captain hands out."""
86        return CompetitionInvites(self._http, self._cid)

Invite codes + direct email invites the captain hands out.

requests: CompetitionRequests
88    @cached_property
89    def requests(self) -> CompetitionRequests:
90        """Pending join requests the captain approves / rejects."""
91        return CompetitionRequests(self._http, self._cid)

Pending join requests the captain approves / rejects.

class CompetitionInvites:
 94class CompetitionInvites:
 95    """Captain-only: the team's outgoing invites for this competition."""
 96
 97    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 98        self._http = http
 99        self._cid = competition_id
100
101    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
102        """Mint an invite code scoped to this comp. ``max_uses=0`` is
103        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
104        ``client.competition(id).register(mode="join", code=…)``."""
105        resp = self._http.request(
106            "POST",
107            f"/competitions/{self._cid}/invites",
108            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
109        )
110        _raise_for_status(resp)
111        return TeamInviteInfo.model_validate(resp.json())
112
113    def list(self) -> PagedList[TeamInviteInfo]:
114        """All outstanding invites for the caller's team in this comp."""
115        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
116        _raise_for_status(resp)
117        return _extract_items(resp.json(), TeamInviteInfo)
118
119    def revoke(self, invite_id: str) -> None:
120        """Invalidate a previously-minted invite."""
121        resp = self._http.request("DELETE", f"/competitions/{self._cid}/invites/{invite_id}")
122        _raise_for_status(resp)
123
124    def send(self, email: str) -> TeamInviteInfo:
125        """Mint an invite addressed to one exact email. The lookup is
126        case-insensitive but otherwise unforgiving (no fuzzy match, no
127        enumeration vectors). The recipient accepts via
128        :meth:`Competition.accept_invite`."""
129        resp = self._http.request(
130            "POST",
131            f"/competitions/{self._cid}/invites/direct",
132            json={"email": email},
133        )
134        _raise_for_status(resp)
135        return TeamInviteInfo.model_validate(resp.json())

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

CompetitionInvites(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
97    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
98        self._http = http
99        self._cid = competition_id
def create( self, *, max_uses: int = 1, ttl_seconds: int = 86400) -> ctfy.server.models.TeamInviteInfo:
101    def create(self, *, max_uses: int = 1, ttl_seconds: int = 24 * 3600) -> TeamInviteInfo:
102        """Mint an invite code scoped to this comp. ``max_uses=0`` is
103        unlimited; ``ttl_seconds=0`` never expires. Others redeem it via
104        ``client.competition(id).register(mode="join", code=…)``."""
105        resp = self._http.request(
106            "POST",
107            f"/competitions/{self._cid}/invites",
108            json={"max_uses": max_uses, "ttl_seconds": ttl_seconds},
109        )
110        _raise_for_status(resp)
111        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]:
113    def list(self) -> PagedList[TeamInviteInfo]:
114        """All outstanding invites for the caller's team in this comp."""
115        resp = self._http.request("GET", f"/competitions/{self._cid}/invites")
116        _raise_for_status(resp)
117        return _extract_items(resp.json(), TeamInviteInfo)

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

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

Invalidate a previously-minted invite.

def send(self, email: str) -> ctfy.server.models.TeamInviteInfo:
124    def send(self, email: str) -> TeamInviteInfo:
125        """Mint an invite addressed to one exact email. The lookup is
126        case-insensitive but otherwise unforgiving (no fuzzy match, no
127        enumeration vectors). The recipient accepts via
128        :meth:`Competition.accept_invite`."""
129        resp = self._http.request(
130            "POST",
131            f"/competitions/{self._cid}/invites/direct",
132            json={"email": email},
133        )
134        _raise_for_status(resp)
135        return TeamInviteInfo.model_validate(resp.json())

Mint an invite addressed to one exact email. The lookup is case-insensitive but otherwise unforgiving (no fuzzy match, no enumeration vectors). The recipient accepts via Competition.accept_invite().

class CompetitionRequests:
138class CompetitionRequests:
139    """Captain-only: incoming join requests for this competition."""
140
141    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
142        self._http = http
143        self._cid = competition_id
144
145    def approve(self, invite_id: str) -> TeamDetail:
146        """Approve a pending join request → the requester joins the team."""
147        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
148        _raise_for_status(resp)
149        return TeamDetail.model_validate(resp.json())
150
151    def reject(self, invite_id: str) -> None:
152        """Reject a pending join request → invite revoked. The requester
153        can re-open a fresh request afterwards."""
154        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/reject")
155        _raise_for_status(resp)

Captain-only: incoming join requests for this competition.

CompetitionRequests(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
141    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
142        self._http = http
143        self._cid = competition_id
def approve(self, invite_id: str) -> ctfy.server.models.TeamDetail:
145    def approve(self, invite_id: str) -> TeamDetail:
146        """Approve a pending join request → the requester joins the team."""
147        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/approve")
148        _raise_for_status(resp)
149        return TeamDetail.model_validate(resp.json())

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

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

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