ctfy.sdk.admin_resources.competitions

client.admin.competitions — competition CRUD + roster (admin).

  1"""``client.admin.competitions`` — competition CRUD + roster (admin)."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6from datetime import datetime
  7from typing import Any
  8
  9from ctfy.core.localized import LocalizedText
 10from ctfy.sdk._helpers import _raise_for_status
 11from ctfy.sdk.base import BaseHttpClient
 12from ctfy.server.models import (
 13    AuthoringScaleInfo,
 14    AwdMatchConfig,
 15    CompetitionFormatPreset,
 16    CompetitionInfo,
 17    CompetitionRecords,
 18    CompetitionRegistrationInfo,
 19)
 20
 21
 22class AdminCompetitionsResource:
 23    """Create / update / delete competitions and inspect their roster."""
 24
 25    def __init__(self, http: BaseHttpClient) -> None:
 26        self._http = http
 27
 28    def formats(self) -> builtins.list[CompetitionFormatPreset]:
 29        """The wizard's named starting points for a new competition.
 30
 31        Each entry is a set of axis values, not a type: apply one to
 32        ``create(...)`` and then override whatever the event needs. The
 33        platform stores no record of which one was used.
 34        """
 35        resp = self._http.request("GET", "/admin/competition-formats")
 36        _raise_for_status(resp)
 37        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]
 38
 39    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
 40        """What this board's ``authoring_weight`` is worth, in challenges.
 41
 42        A ``peer_authoring`` weight is a fixed multiplier on a curve
 43        output, so on its own it says nothing: 200 is generous on a board
 44        of 100-point flags and a rounding error on one of 5000. This
 45        prices it against the competition's own challenge set.
 46
 47        Read-only, and computed from the **stored** rule and challenge
 48        set — a weight you have not saved is not what this answers about.
 49        """
 50        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
 51        _raise_for_status(resp)
 52        return AuthoringScaleInfo.model_validate(resp.json())
 53
 54    def records(self, competition_id: str) -> CompetitionRecords:
 55        """Every record of one competition, for scoring outside the platform.
 56
 57        Teams (with each agent's owner and the owner's team), solves as the
 58        board ranks them, submissions without their text, agent runs, and
 59        uploads with their authors — complete, never a page.
 60        """
 61        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
 62        _raise_for_status(resp)
 63        return CompetitionRecords.model_validate(resp.json())
 64
 65    def list(self, offset: int = 0, limit: int = 50) -> builtins.list[CompetitionInfo]:
 66        """Every competition — past, current, scheduled."""
 67        resp = self._http.request(
 68            "GET", "/admin/competitions", params={"offset": offset, "limit": limit}
 69        )
 70        _raise_for_status(resp)
 71        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]
 72
 73    def create(
 74        self,
 75        *,
 76        title: str,
 77        description: str | LocalizedText = "",
 78        handbook: str | LocalizedText = "",
 79        starts_at: datetime | None = None,
 80        ends_at: datetime | None = None,
 81        challenge_ids: builtins.list[str] | None = None,
 82        status: str = "draft",
 83        access: str = "public",
 84        eval_standard: bool = False,
 85        corpus_sha: str = "",
 86        scoring_rule: str = "",
 87        scoring_params: dict[str, Any] | None = None,
 88        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
 89        awd_match: AwdMatchConfig | None = None,
 90        entrants_are_agents: bool = False,
 91    ) -> CompetitionInfo:
 92        """Create a new competition. ``None`` time fields = no bound
 93        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
 94        ``status`` defaults to ``draft`` (hidden from players); pass
 95        ``"published"`` to make it immediately visible. ``access``
 96        defaults to ``public``; pass ``"private_listed"`` /
 97        ``"private_hidden"`` for an invite-only competition.
 98        ``eval_standard=True`` marks it an official eval standard set (only
 99        such a competition may back an official EvalCampaign); ``corpus_sha``
100        pins the corpus snapshot it was certified against.
101        ``scoring_rule`` selects the board's ranking algorithm
102        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
103        ``scoring_params`` carrying its tuning; set it here where you can,
104        because scoring freezes once the competition goes live.
105        ``sessions`` declares the playable slices of the window as
106        ``(start, end)`` pairs — a three-day event running 08:00-16:00
107        daily is three of them. Supplying it also *sets* ``starts_at`` /
108        ``ends_at``, which the server derives from the envelope, and
109        opts the competition into the play gate: outside a session,
110        submissions and instance starts are refused and running
111        instances are torn down. Omit it for a single continuous
112        window, which is how every competition behaves by default.
113        ``handbook`` is the organiser's long-form rules page, markdown,
114        capped server-side — ``description`` stays the one-line blurb the
115        listing and the page header truncate. Both are localised: pass a
116        ``{locale: text}`` map to write one translation, or a bare string,
117        which the server reads as ``en``. ⚠️ **A bare string is kept in the
118        signature rather than deprecated** — every caller that predates
119        localisation sends one, and a 422 for all of them would be a
120        breaking change dressed up as a feature; the coercion is the
121        server's (``coerce_localized``), so the two cannot disagree.
122        ``entrants_are_agents=True`` marks it an agent exhibition: human
123        registration is refused server-side and the browser shows a
124        spectate affordance rather than a sign-up button."""
125        resp = self._http.request(
126            "POST",
127            "/admin/competitions",
128            json={
129                "title": title,
130                "description": description,
131                "handbook": handbook,
132                "starts_at": starts_at.isoformat() if starts_at else None,
133                "ends_at": ends_at.isoformat() if ends_at else None,
134                "challenge_ids": challenge_ids or [],
135                "status": status,
136                "access": access,
137                "eval_standard": eval_standard,
138                "corpus_sha": corpus_sha,
139                "scoring_rule": scoring_rule,
140                "scoring_params": scoring_params or {},
141                # An agent exhibition: entrants are dispatched agents, so
142                # human registration is refused and the browser offers a
143                # spectate affordance. See ``CompetitionCreate``.
144                "entrants_are_agents": entrants_are_agents,
145                "sessions": [
146                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
147                    for s, e in (sessions or [])
148                ],
149                # ⚠️ **Absent, never `{}`.** The server's model is
150                # `extra="forbid"` with two required cadence fields, so
151                # an empty object is a 422 on every competition that
152                # runs no rounds. `None` here means "not configured in
153                # this request", which the upsert route can still
154                # satisfy tomorrow — never "this competition has no
155                # match".
156                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
157            },
158        )
159        _raise_for_status(resp)
160        return CompetitionInfo.model_validate(resp.json())
161
162    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
163        """PATCH any subset of ``title`` / ``description`` / ``starts_at`` /
164        ``ends_at`` / ``challenge_ids`` / ``status`` / ``access`` /
165        ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
166        ``scoring_params`` / ``sessions`` / ``anonymous_entry`` /
167        ``anonymous_entry_kind``. ⚠️ This is a **passthrough** — the list
168        is what the wire accepts today, so it is a pointer to
169        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
170        the narrow one (it builds its body by hand and cannot carry a
171        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
172        freely and is refused on a narrowing once entrants have relied
173        on it, ``required`` → ``optional`` included: that transition
174        un-anonymises the whole field at once. Omit a field to leave it; pass
175        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
176        meaningful rather than empty: it clears the schedule and returns
177        the competition to a single continuous window. Changing the scoring fields on a live competition is
178        rejected with 409 — see ``competitions.scoring_locked``."""
179        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
180        _raise_for_status(resp)
181        return CompetitionInfo.model_validate(resp.json())
182
183    def delete(self, competition_id: str) -> None:
184        """Hard-delete a competition. Cascades against per-comp teams,
185        memberships, invites, solve / submission rows."""
186        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
187        _raise_for_status(resp)
188
189    def registrations(
190        self,
191        competition_id: str,
192        offset: int = 0,
193        limit: int = 50,
194    ) -> builtins.list[CompetitionRegistrationInfo]:
195        """Roster for ``competition_id``. Per the per-comp refactor, a
196        team's existence with the comp scope IS the registration."""
197        resp = self._http.request(
198            "GET",
199            f"/admin/competitions/{competition_id}/registrations",
200            params={"offset": offset, "limit": limit},
201        )
202        _raise_for_status(resp)
203        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]
class AdminCompetitionsResource:
 23class AdminCompetitionsResource:
 24    """Create / update / delete competitions and inspect their roster."""
 25
 26    def __init__(self, http: BaseHttpClient) -> None:
 27        self._http = http
 28
 29    def formats(self) -> builtins.list[CompetitionFormatPreset]:
 30        """The wizard's named starting points for a new competition.
 31
 32        Each entry is a set of axis values, not a type: apply one to
 33        ``create(...)`` and then override whatever the event needs. The
 34        platform stores no record of which one was used.
 35        """
 36        resp = self._http.request("GET", "/admin/competition-formats")
 37        _raise_for_status(resp)
 38        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]
 39
 40    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
 41        """What this board's ``authoring_weight`` is worth, in challenges.
 42
 43        A ``peer_authoring`` weight is a fixed multiplier on a curve
 44        output, so on its own it says nothing: 200 is generous on a board
 45        of 100-point flags and a rounding error on one of 5000. This
 46        prices it against the competition's own challenge set.
 47
 48        Read-only, and computed from the **stored** rule and challenge
 49        set — a weight you have not saved is not what this answers about.
 50        """
 51        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
 52        _raise_for_status(resp)
 53        return AuthoringScaleInfo.model_validate(resp.json())
 54
 55    def records(self, competition_id: str) -> CompetitionRecords:
 56        """Every record of one competition, for scoring outside the platform.
 57
 58        Teams (with each agent's owner and the owner's team), solves as the
 59        board ranks them, submissions without their text, agent runs, and
 60        uploads with their authors — complete, never a page.
 61        """
 62        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
 63        _raise_for_status(resp)
 64        return CompetitionRecords.model_validate(resp.json())
 65
 66    def list(self, offset: int = 0, limit: int = 50) -> builtins.list[CompetitionInfo]:
 67        """Every competition — past, current, scheduled."""
 68        resp = self._http.request(
 69            "GET", "/admin/competitions", params={"offset": offset, "limit": limit}
 70        )
 71        _raise_for_status(resp)
 72        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]
 73
 74    def create(
 75        self,
 76        *,
 77        title: str,
 78        description: str | LocalizedText = "",
 79        handbook: str | LocalizedText = "",
 80        starts_at: datetime | None = None,
 81        ends_at: datetime | None = None,
 82        challenge_ids: builtins.list[str] | None = None,
 83        status: str = "draft",
 84        access: str = "public",
 85        eval_standard: bool = False,
 86        corpus_sha: str = "",
 87        scoring_rule: str = "",
 88        scoring_params: dict[str, Any] | None = None,
 89        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
 90        awd_match: AwdMatchConfig | None = None,
 91        entrants_are_agents: bool = False,
 92    ) -> CompetitionInfo:
 93        """Create a new competition. ``None`` time fields = no bound
 94        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
 95        ``status`` defaults to ``draft`` (hidden from players); pass
 96        ``"published"`` to make it immediately visible. ``access``
 97        defaults to ``public``; pass ``"private_listed"`` /
 98        ``"private_hidden"`` for an invite-only competition.
 99        ``eval_standard=True`` marks it an official eval standard set (only
100        such a competition may back an official EvalCampaign); ``corpus_sha``
101        pins the corpus snapshot it was certified against.
102        ``scoring_rule`` selects the board's ranking algorithm
103        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
104        ``scoring_params`` carrying its tuning; set it here where you can,
105        because scoring freezes once the competition goes live.
106        ``sessions`` declares the playable slices of the window as
107        ``(start, end)`` pairs — a three-day event running 08:00-16:00
108        daily is three of them. Supplying it also *sets* ``starts_at`` /
109        ``ends_at``, which the server derives from the envelope, and
110        opts the competition into the play gate: outside a session,
111        submissions and instance starts are refused and running
112        instances are torn down. Omit it for a single continuous
113        window, which is how every competition behaves by default.
114        ``handbook`` is the organiser's long-form rules page, markdown,
115        capped server-side — ``description`` stays the one-line blurb the
116        listing and the page header truncate. Both are localised: pass a
117        ``{locale: text}`` map to write one translation, or a bare string,
118        which the server reads as ``en``. ⚠️ **A bare string is kept in the
119        signature rather than deprecated** — every caller that predates
120        localisation sends one, and a 422 for all of them would be a
121        breaking change dressed up as a feature; the coercion is the
122        server's (``coerce_localized``), so the two cannot disagree.
123        ``entrants_are_agents=True`` marks it an agent exhibition: human
124        registration is refused server-side and the browser shows a
125        spectate affordance rather than a sign-up button."""
126        resp = self._http.request(
127            "POST",
128            "/admin/competitions",
129            json={
130                "title": title,
131                "description": description,
132                "handbook": handbook,
133                "starts_at": starts_at.isoformat() if starts_at else None,
134                "ends_at": ends_at.isoformat() if ends_at else None,
135                "challenge_ids": challenge_ids or [],
136                "status": status,
137                "access": access,
138                "eval_standard": eval_standard,
139                "corpus_sha": corpus_sha,
140                "scoring_rule": scoring_rule,
141                "scoring_params": scoring_params or {},
142                # An agent exhibition: entrants are dispatched agents, so
143                # human registration is refused and the browser offers a
144                # spectate affordance. See ``CompetitionCreate``.
145                "entrants_are_agents": entrants_are_agents,
146                "sessions": [
147                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
148                    for s, e in (sessions or [])
149                ],
150                # ⚠️ **Absent, never `{}`.** The server's model is
151                # `extra="forbid"` with two required cadence fields, so
152                # an empty object is a 422 on every competition that
153                # runs no rounds. `None` here means "not configured in
154                # this request", which the upsert route can still
155                # satisfy tomorrow — never "this competition has no
156                # match".
157                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
158            },
159        )
160        _raise_for_status(resp)
161        return CompetitionInfo.model_validate(resp.json())
162
163    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
164        """PATCH any subset of ``title`` / ``description`` / ``starts_at`` /
165        ``ends_at`` / ``challenge_ids`` / ``status`` / ``access`` /
166        ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
167        ``scoring_params`` / ``sessions`` / ``anonymous_entry`` /
168        ``anonymous_entry_kind``. ⚠️ This is a **passthrough** — the list
169        is what the wire accepts today, so it is a pointer to
170        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
171        the narrow one (it builds its body by hand and cannot carry a
172        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
173        freely and is refused on a narrowing once entrants have relied
174        on it, ``required`` → ``optional`` included: that transition
175        un-anonymises the whole field at once. Omit a field to leave it; pass
176        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
177        meaningful rather than empty: it clears the schedule and returns
178        the competition to a single continuous window. Changing the scoring fields on a live competition is
179        rejected with 409 — see ``competitions.scoring_locked``."""
180        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
181        _raise_for_status(resp)
182        return CompetitionInfo.model_validate(resp.json())
183
184    def delete(self, competition_id: str) -> None:
185        """Hard-delete a competition. Cascades against per-comp teams,
186        memberships, invites, solve / submission rows."""
187        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
188        _raise_for_status(resp)
189
190    def registrations(
191        self,
192        competition_id: str,
193        offset: int = 0,
194        limit: int = 50,
195    ) -> builtins.list[CompetitionRegistrationInfo]:
196        """Roster for ``competition_id``. Per the per-comp refactor, a
197        team's existence with the comp scope IS the registration."""
198        resp = self._http.request(
199            "GET",
200            f"/admin/competitions/{competition_id}/registrations",
201            params={"offset": offset, "limit": limit},
202        )
203        _raise_for_status(resp)
204        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]

Create / update / delete competitions and inspect their roster.

AdminCompetitionsResource(http: ctfy.sdk.base.BaseHttpClient)
26    def __init__(self, http: BaseHttpClient) -> None:
27        self._http = http
def formats(self) -> list[ctfy.server.models.CompetitionFormatPreset]:
29    def formats(self) -> builtins.list[CompetitionFormatPreset]:
30        """The wizard's named starting points for a new competition.
31
32        Each entry is a set of axis values, not a type: apply one to
33        ``create(...)`` and then override whatever the event needs. The
34        platform stores no record of which one was used.
35        """
36        resp = self._http.request("GET", "/admin/competition-formats")
37        _raise_for_status(resp)
38        return [CompetitionFormatPreset.model_validate(f) for f in resp.json()]

The wizard's named starting points for a new competition.

Each entry is a set of axis values, not a type: apply one to create(...) and then override whatever the event needs. The platform stores no record of which one was used.

def authoring_scale( self, competition_id: str) -> ctfy.server.models.AuthoringScaleInfo:
40    def authoring_scale(self, competition_id: str) -> AuthoringScaleInfo:
41        """What this board's ``authoring_weight`` is worth, in challenges.
42
43        A ``peer_authoring`` weight is a fixed multiplier on a curve
44        output, so on its own it says nothing: 200 is generous on a board
45        of 100-point flags and a rounding error on one of 5000. This
46        prices it against the competition's own challenge set.
47
48        Read-only, and computed from the **stored** rule and challenge
49        set — a weight you have not saved is not what this answers about.
50        """
51        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/authoring-scale")
52        _raise_for_status(resp)
53        return AuthoringScaleInfo.model_validate(resp.json())

What this board's authoring_weight is worth, in challenges.

A peer_authoring weight is a fixed multiplier on a curve output, so on its own it says nothing: 200 is generous on a board of 100-point flags and a rounding error on one of 5000. This prices it against the competition's own challenge set.

Read-only, and computed from the stored rule and challenge set — a weight you have not saved is not what this answers about.

def records( self, competition_id: str) -> ctfy.server.models.CompetitionRecords:
55    def records(self, competition_id: str) -> CompetitionRecords:
56        """Every record of one competition, for scoring outside the platform.
57
58        Teams (with each agent's owner and the owner's team), solves as the
59        board ranks them, submissions without their text, agent runs, and
60        uploads with their authors — complete, never a page.
61        """
62        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/records")
63        _raise_for_status(resp)
64        return CompetitionRecords.model_validate(resp.json())

Every record of one competition, for scoring outside the platform.

Teams (with each agent's owner and the owner's team), solves as the board ranks them, submissions without their text, agent runs, and uploads with their authors — complete, never a page.

def list( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.CompetitionInfo]:
66    def list(self, offset: int = 0, limit: int = 50) -> builtins.list[CompetitionInfo]:
67        """Every competition — past, current, scheduled."""
68        resp = self._http.request(
69            "GET", "/admin/competitions", params={"offset": offset, "limit": limit}
70        )
71        _raise_for_status(resp)
72        return [CompetitionInfo.model_validate(c) for c in resp.json()["items"]]

Every competition — past, current, scheduled.

def create( self, *, title: str, description: str | dict[str, str] = '', handbook: str | dict[str, str] = '', starts_at: datetime.datetime | None = None, ends_at: datetime.datetime | None = None, challenge_ids: list[str] | None = None, status: str = 'draft', access: str = 'public', eval_standard: bool = False, corpus_sha: str = '', scoring_rule: str = '', scoring_params: dict[str, typing.Any] | None = None, sessions: list[tuple[datetime.datetime, datetime.datetime]] | None = None, awd_match: ctfy.server.models.AwdMatchConfig | None = None, entrants_are_agents: bool = False) -> ctfy.server.models.CompetitionInfo:
 74    def create(
 75        self,
 76        *,
 77        title: str,
 78        description: str | LocalizedText = "",
 79        handbook: str | LocalizedText = "",
 80        starts_at: datetime | None = None,
 81        ends_at: datetime | None = None,
 82        challenge_ids: builtins.list[str] | None = None,
 83        status: str = "draft",
 84        access: str = "public",
 85        eval_standard: bool = False,
 86        corpus_sha: str = "",
 87        scoring_rule: str = "",
 88        scoring_params: dict[str, Any] | None = None,
 89        sessions: builtins.list[tuple[datetime, datetime]] | None = None,
 90        awd_match: AwdMatchConfig | None = None,
 91        entrants_are_agents: bool = False,
 92    ) -> CompetitionInfo:
 93        """Create a new competition. ``None`` time fields = no bound
 94        (sent as JSON ``null``). Unknown ``challenge_ids`` → 422.
 95        ``status`` defaults to ``draft`` (hidden from players); pass
 96        ``"published"`` to make it immediately visible. ``access``
 97        defaults to ``public``; pass ``"private_listed"`` /
 98        ``"private_hidden"`` for an invite-only competition.
 99        ``eval_standard=True`` marks it an official eval standard set (only
100        such a competition may back an official EvalCampaign); ``corpus_sha``
101        pins the corpus snapshot it was certified against.
102        ``scoring_rule`` selects the board's ranking algorithm
103        (``""`` = flag count, ``static_points``, ``dynamic_points``) with
104        ``scoring_params`` carrying its tuning; set it here where you can,
105        because scoring freezes once the competition goes live.
106        ``sessions`` declares the playable slices of the window as
107        ``(start, end)`` pairs — a three-day event running 08:00-16:00
108        daily is three of them. Supplying it also *sets* ``starts_at`` /
109        ``ends_at``, which the server derives from the envelope, and
110        opts the competition into the play gate: outside a session,
111        submissions and instance starts are refused and running
112        instances are torn down. Omit it for a single continuous
113        window, which is how every competition behaves by default.
114        ``handbook`` is the organiser's long-form rules page, markdown,
115        capped server-side — ``description`` stays the one-line blurb the
116        listing and the page header truncate. Both are localised: pass a
117        ``{locale: text}`` map to write one translation, or a bare string,
118        which the server reads as ``en``. ⚠️ **A bare string is kept in the
119        signature rather than deprecated** — every caller that predates
120        localisation sends one, and a 422 for all of them would be a
121        breaking change dressed up as a feature; the coercion is the
122        server's (``coerce_localized``), so the two cannot disagree.
123        ``entrants_are_agents=True`` marks it an agent exhibition: human
124        registration is refused server-side and the browser shows a
125        spectate affordance rather than a sign-up button."""
126        resp = self._http.request(
127            "POST",
128            "/admin/competitions",
129            json={
130                "title": title,
131                "description": description,
132                "handbook": handbook,
133                "starts_at": starts_at.isoformat() if starts_at else None,
134                "ends_at": ends_at.isoformat() if ends_at else None,
135                "challenge_ids": challenge_ids or [],
136                "status": status,
137                "access": access,
138                "eval_standard": eval_standard,
139                "corpus_sha": corpus_sha,
140                "scoring_rule": scoring_rule,
141                "scoring_params": scoring_params or {},
142                # An agent exhibition: entrants are dispatched agents, so
143                # human registration is refused and the browser offers a
144                # spectate affordance. See ``CompetitionCreate``.
145                "entrants_are_agents": entrants_are_agents,
146                "sessions": [
147                    {"starts_at": s.isoformat(), "ends_at": e.isoformat()}
148                    for s, e in (sessions or [])
149                ],
150                # ⚠️ **Absent, never `{}`.** The server's model is
151                # `extra="forbid"` with two required cadence fields, so
152                # an empty object is a 422 on every competition that
153                # runs no rounds. `None` here means "not configured in
154                # this request", which the upsert route can still
155                # satisfy tomorrow — never "this competition has no
156                # match".
157                **({"awd_match": awd_match.model_dump(mode="json")} if awd_match else {}),
158            },
159        )
160        _raise_for_status(resp)
161        return CompetitionInfo.model_validate(resp.json())

Create a new competition. None time fields = no bound (sent as JSON null). Unknown challenge_ids → 422. status defaults to draft (hidden from players); pass "published" to make it immediately visible. access defaults to public; pass "private_listed" / "private_hidden" for an invite-only competition. eval_standard=True marks it an official eval standard set (only such a competition may back an official EvalCampaign); corpus_sha pins the corpus snapshot it was certified against. scoring_rule selects the board's ranking algorithm ("" = flag count, static_points, dynamic_points) with scoring_params carrying its tuning; set it here where you can, because scoring freezes once the competition goes live. sessions declares the playable slices of the window as (start, end) pairs — a three-day event running 08:00-16:00 daily is three of them. Supplying it also sets starts_at / ends_at, which the server derives from the envelope, and opts the competition into the play gate: outside a session, submissions and instance starts are refused and running instances are torn down. Omit it for a single continuous window, which is how every competition behaves by default. handbook is the organiser's long-form rules page, markdown, capped server-side — description stays the one-line blurb the listing and the page header truncate. Both are localised: pass a {locale: text} map to write one translation, or a bare string, which the server reads as en. ⚠️ A bare string is kept in the signature rather than deprecated — every caller that predates localisation sends one, and a 422 for all of them would be a breaking change dressed up as a feature; the coercion is the server's (coerce_localized), so the two cannot disagree. entrants_are_agents=True marks it an agent exhibition: human registration is refused server-side and the browser shows a spectate affordance rather than a sign-up button.

def update( self, competition_id: str, **fields: Any) -> ctfy.server.models.CompetitionInfo:
163    def update(self, competition_id: str, **fields: Any) -> CompetitionInfo:
164        """PATCH any subset of ``title`` / ``description`` / ``starts_at`` /
165        ``ends_at`` / ``challenge_ids`` / ``status`` / ``access`` /
166        ``eval_standard`` / ``corpus_sha`` / ``scoring_rule`` /
167        ``scoring_params`` / ``sessions`` / ``anonymous_entry`` /
168        ``anonymous_entry_kind``. ⚠️ This is a **passthrough** — the list
169        is what the wire accepts today, so it is a pointer to
170        ``CompetitionUpdate`` rather than a gate, and :meth:`create` is
171        the narrow one (it builds its body by hand and cannot carry a
172        field it does not name). ⚠️ ``anonymous_entry`` may be *widened*
173        freely and is refused on a narrowing once entrants have relied
174        on it, ``required`` → ``optional`` included: that transition
175        un-anonymises the whole field at once. Omit a field to leave it; pass
176        ``None`` to clear. ``sessions`` is the one field where ``[]`` is
177        meaningful rather than empty: it clears the schedule and returns
178        the competition to a single continuous window. Changing the scoring fields on a live competition is
179        rejected with 409 — see ``competitions.scoring_locked``."""
180        resp = self._http.request("PATCH", f"/admin/competitions/{competition_id}", json=fields)
181        _raise_for_status(resp)
182        return CompetitionInfo.model_validate(resp.json())

PATCH any subset of title / description / starts_at / ends_at / challenge_ids / status / access / eval_standard / corpus_sha / scoring_rule / scoring_params / sessions / anonymous_entry / anonymous_entry_kind. ⚠️ This is a passthrough — the list is what the wire accepts today, so it is a pointer to CompetitionUpdate rather than a gate, and create() is the narrow one (it builds its body by hand and cannot carry a field it does not name). ⚠️ anonymous_entry may be widened freely and is refused on a narrowing once entrants have relied on it, required → optional included: that transition un-anonymises the whole field at once. Omit a field to leave it; pass None to clear. sessions is the one field where [] is meaningful rather than empty: it clears the schedule and returns the competition to a single continuous window. Changing the scoring fields on a live competition is rejected with 409 — see competitions.scoring_locked.

def delete(self, competition_id: str) -> None:
184    def delete(self, competition_id: str) -> None:
185        """Hard-delete a competition. Cascades against per-comp teams,
186        memberships, invites, solve / submission rows."""
187        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}")
188        _raise_for_status(resp)

Hard-delete a competition. Cascades against per-comp teams, memberships, invites, solve / submission rows.

def registrations( self, competition_id: str, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.CompetitionRegistrationInfo]:
190    def registrations(
191        self,
192        competition_id: str,
193        offset: int = 0,
194        limit: int = 50,
195    ) -> builtins.list[CompetitionRegistrationInfo]:
196        """Roster for ``competition_id``. Per the per-comp refactor, a
197        team's existence with the comp scope IS the registration."""
198        resp = self._http.request(
199            "GET",
200            f"/admin/competitions/{competition_id}/registrations",
201            params={"offset": offset, "limit": limit},
202        )
203        _raise_for_status(resp)
204        return [CompetitionRegistrationInfo.model_validate(r) for r in resp.json()["items"]]

Roster for competition_id. Per the per-comp refactor, a team's existence with the comp scope IS the registration.