ctfy.sdk.admin_resources.registrations

client.admin.registrations — the organiser's roster, review and export.

Every call here is gated server-side on require_registration_reviewer and, where sensitive fields are collected, lands an audit row naming the caller. Treat the returned rows as personal data: they carry legal names, phone numbers and postal addresses.

  1"""``client.admin.registrations`` — the organiser's roster, review and export.
  2
  3Every call here is gated server-side on ``require_registration_reviewer``
  4and, where sensitive fields are collected, lands an audit row naming the
  5caller. Treat the returned rows as personal data: they carry legal names,
  6phone numbers and postal addresses.
  7"""
  8
  9from __future__ import annotations
 10
 11import builtins
 12
 13from ctfy.sdk._helpers import _raise_for_status
 14from ctfy.sdk.base import BaseHttpClient
 15from ctfy.server.models import (
 16    RegistrationSubmitRequest,
 17    RegistrationSummary,
 18    RegistrationTeamRow,
 19)
 20
 21
 22class AdminRegistrationsResource:
 23    def __init__(self, http: BaseHttpClient) -> None:
 24        self._http = http
 25
 26    def roster(
 27        self,
 28        competition_id: str,
 29        *,
 30        status: str = "",
 31        offset: int = 0,
 32        limit: int = 50,
 33    ) -> builtins.list[RegistrationTeamRow]:
 34        """One page of the roster with entrant details, grouped by team.
 35
 36        Paged and verdict-filtered server-side: each dossier costs an
 37        AES-GCM open per sealed field, so a thousand-entrant competition
 38        cannot afford "fetch everything and filter here" — which is
 39        exactly what this used to do.
 40
 41        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
 42        means every verdict. Use :meth:`roster_all` to walk the whole
 43        roster. Each call is audited as a PII read.
 44        """
 45        params: dict[str, object] = {"offset": offset, "limit": limit}
 46        if status:
 47            params["status"] = status
 48        resp = self._http.request(
 49            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
 50        )
 51        _raise_for_status(resp)
 52        return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]
 53
 54    def roster_all(
 55        self, competition_id: str, *, status: str = "", page_size: int = 200
 56    ) -> builtins.list[RegistrationTeamRow]:
 57        """Every page, concatenated — for scripts that genuinely want all.
 58
 59        Kept explicit rather than making :meth:`roster` unbounded, so
 60        "walk the whole roster" is a decision a caller makes rather than
 61        the default cost of asking for any of it. Note each page lands
 62        its own audit row, which is correct: this reads every entrant's
 63        personal data.
 64        """
 65        out: builtins.list[RegistrationTeamRow] = []
 66        offset = 0
 67        while True:
 68            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
 69            out.extend(page)
 70            if len(page) < page_size:
 71                return out
 72            offset += page_size
 73
 74    def summary(self, competition_id: str) -> RegistrationSummary:
 75        """Per-verdict totals for the whole competition.
 76
 77        What the roster's filter tabs are labelled with — counting a page
 78        would report the page size. Carries no personal data, so unlike
 79        :meth:`roster` it is not audited.
 80        """
 81        resp = self._http.request(
 82            "GET", f"/admin/competitions/{competition_id}/registration-summary"
 83        )
 84        _raise_for_status(resp)
 85        return RegistrationSummary.model_validate(resp.json())
 86
 87    def review(
 88        self, competition_id: str, team_id: str, *, status: str, note: str = ""
 89    ) -> RegistrationTeamRow:
 90        """Rule on one squad's eligibility.
 91
 92        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
 93        rejection requires a ``note`` — the server rejects an empty one,
 94        because "rejected, no reason given" becomes a support ticket every
 95        time. Members are emailed the verdict when email is configured.
 96        """
 97        resp = self._http.request(
 98            "POST",
 99            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
100            json={"status": status, "note": note},
101        )
102        _raise_for_status(resp)
103        return RegistrationTeamRow.model_validate(resp.json())
104
105    def edit(
106        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
107    ) -> RegistrationTeamRow:
108        """Correct one entrant's dossier on their behalf.
109
110        For the two cases the entrant's own form cannot serve: a detail
111        taken down wrong that only the organiser can see is wrong, and a
112        registration completed over the phone or at a desk.
113
114        **Not gated on the registration deadline** — correcting a record
115        is not entering the competition, and a typo that becomes
116        uncorrectable the moment sign-up closes is uncorrectable for
117        exactly the period the roster gets read. Lands a
118        ``registration_edited`` audit row naming the caller, the subject
119        and which fields moved (never their values).
120        """
121        resp = self._http.request(
122            "PUT",
123            f"/admin/competitions/{competition_id}/registrations/{user_id}",
124            json=answers.model_dump(mode="json"),
125        )
126        _raise_for_status(resp)
127        return RegistrationTeamRow.model_validate(resp.json())
128
129    def export_csv(self, competition_id: str) -> bytes:
130        """The organiser's 基本信息 sheet as CSV bytes.
131
132        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
133        correctly. Write the bytes verbatim — re-encoding through ``str``
134        will usually drop the BOM and reintroduce the mojibake.
135        """
136        resp = self._http.request(
137            "GET", f"/admin/competitions/{competition_id}/registration-export"
138        )
139        _raise_for_status(resp)
140        return resp.content
class AdminRegistrationsResource:
 23class AdminRegistrationsResource:
 24    def __init__(self, http: BaseHttpClient) -> None:
 25        self._http = http
 26
 27    def roster(
 28        self,
 29        competition_id: str,
 30        *,
 31        status: str = "",
 32        offset: int = 0,
 33        limit: int = 50,
 34    ) -> builtins.list[RegistrationTeamRow]:
 35        """One page of the roster with entrant details, grouped by team.
 36
 37        Paged and verdict-filtered server-side: each dossier costs an
 38        AES-GCM open per sealed field, so a thousand-entrant competition
 39        cannot afford "fetch everything and filter here" — which is
 40        exactly what this used to do.
 41
 42        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
 43        means every verdict. Use :meth:`roster_all` to walk the whole
 44        roster. Each call is audited as a PII read.
 45        """
 46        params: dict[str, object] = {"offset": offset, "limit": limit}
 47        if status:
 48            params["status"] = status
 49        resp = self._http.request(
 50            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
 51        )
 52        _raise_for_status(resp)
 53        return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]
 54
 55    def roster_all(
 56        self, competition_id: str, *, status: str = "", page_size: int = 200
 57    ) -> builtins.list[RegistrationTeamRow]:
 58        """Every page, concatenated — for scripts that genuinely want all.
 59
 60        Kept explicit rather than making :meth:`roster` unbounded, so
 61        "walk the whole roster" is a decision a caller makes rather than
 62        the default cost of asking for any of it. Note each page lands
 63        its own audit row, which is correct: this reads every entrant's
 64        personal data.
 65        """
 66        out: builtins.list[RegistrationTeamRow] = []
 67        offset = 0
 68        while True:
 69            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
 70            out.extend(page)
 71            if len(page) < page_size:
 72                return out
 73            offset += page_size
 74
 75    def summary(self, competition_id: str) -> RegistrationSummary:
 76        """Per-verdict totals for the whole competition.
 77
 78        What the roster's filter tabs are labelled with — counting a page
 79        would report the page size. Carries no personal data, so unlike
 80        :meth:`roster` it is not audited.
 81        """
 82        resp = self._http.request(
 83            "GET", f"/admin/competitions/{competition_id}/registration-summary"
 84        )
 85        _raise_for_status(resp)
 86        return RegistrationSummary.model_validate(resp.json())
 87
 88    def review(
 89        self, competition_id: str, team_id: str, *, status: str, note: str = ""
 90    ) -> RegistrationTeamRow:
 91        """Rule on one squad's eligibility.
 92
 93        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
 94        rejection requires a ``note`` — the server rejects an empty one,
 95        because "rejected, no reason given" becomes a support ticket every
 96        time. Members are emailed the verdict when email is configured.
 97        """
 98        resp = self._http.request(
 99            "POST",
100            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
101            json={"status": status, "note": note},
102        )
103        _raise_for_status(resp)
104        return RegistrationTeamRow.model_validate(resp.json())
105
106    def edit(
107        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
108    ) -> RegistrationTeamRow:
109        """Correct one entrant's dossier on their behalf.
110
111        For the two cases the entrant's own form cannot serve: a detail
112        taken down wrong that only the organiser can see is wrong, and a
113        registration completed over the phone or at a desk.
114
115        **Not gated on the registration deadline** — correcting a record
116        is not entering the competition, and a typo that becomes
117        uncorrectable the moment sign-up closes is uncorrectable for
118        exactly the period the roster gets read. Lands a
119        ``registration_edited`` audit row naming the caller, the subject
120        and which fields moved (never their values).
121        """
122        resp = self._http.request(
123            "PUT",
124            f"/admin/competitions/{competition_id}/registrations/{user_id}",
125            json=answers.model_dump(mode="json"),
126        )
127        _raise_for_status(resp)
128        return RegistrationTeamRow.model_validate(resp.json())
129
130    def export_csv(self, competition_id: str) -> bytes:
131        """The organiser's 基本信息 sheet as CSV bytes.
132
133        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
134        correctly. Write the bytes verbatim — re-encoding through ``str``
135        will usually drop the BOM and reintroduce the mojibake.
136        """
137        resp = self._http.request(
138            "GET", f"/admin/competitions/{competition_id}/registration-export"
139        )
140        _raise_for_status(resp)
141        return resp.content
AdminRegistrationsResource(http: ctfy.sdk.base.BaseHttpClient)
24    def __init__(self, http: BaseHttpClient) -> None:
25        self._http = http
def roster( self, competition_id: str, *, status: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.server.models.RegistrationTeamRow]:
27    def roster(
28        self,
29        competition_id: str,
30        *,
31        status: str = "",
32        offset: int = 0,
33        limit: int = 50,
34    ) -> builtins.list[RegistrationTeamRow]:
35        """One page of the roster with entrant details, grouped by team.
36
37        Paged and verdict-filtered server-side: each dossier costs an
38        AES-GCM open per sealed field, so a thousand-entrant competition
39        cannot afford "fetch everything and filter here" — which is
40        exactly what this used to do.
41
42        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
43        means every verdict. Use :meth:`roster_all` to walk the whole
44        roster. Each call is audited as a PII read.
45        """
46        params: dict[str, object] = {"offset": offset, "limit": limit}
47        if status:
48            params["status"] = status
49        resp = self._http.request(
50            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
51        )
52        _raise_for_status(resp)
53        return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]

One page of the roster with entrant details, grouped by team.

Paged and verdict-filtered server-side: each dossier costs an AES-GCM open per sealed field, so a thousand-entrant competition cannot afford "fetch everything and filter here" — which is exactly what this used to do.

status is pending / approved / rejected; empty means every verdict. Use roster_all() to walk the whole roster. Each call is audited as a PII read.

def roster_all( self, competition_id: str, *, status: str = '', page_size: int = 200) -> list[ctfy.server.models.RegistrationTeamRow]:
55    def roster_all(
56        self, competition_id: str, *, status: str = "", page_size: int = 200
57    ) -> builtins.list[RegistrationTeamRow]:
58        """Every page, concatenated — for scripts that genuinely want all.
59
60        Kept explicit rather than making :meth:`roster` unbounded, so
61        "walk the whole roster" is a decision a caller makes rather than
62        the default cost of asking for any of it. Note each page lands
63        its own audit row, which is correct: this reads every entrant's
64        personal data.
65        """
66        out: builtins.list[RegistrationTeamRow] = []
67        offset = 0
68        while True:
69            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
70            out.extend(page)
71            if len(page) < page_size:
72                return out
73            offset += page_size

Every page, concatenated — for scripts that genuinely want all.

Kept explicit rather than making roster() unbounded, so "walk the whole roster" is a decision a caller makes rather than the default cost of asking for any of it. Note each page lands its own audit row, which is correct: this reads every entrant's personal data.

def summary( self, competition_id: str) -> ctfy.server.models.RegistrationSummary:
75    def summary(self, competition_id: str) -> RegistrationSummary:
76        """Per-verdict totals for the whole competition.
77
78        What the roster's filter tabs are labelled with — counting a page
79        would report the page size. Carries no personal data, so unlike
80        :meth:`roster` it is not audited.
81        """
82        resp = self._http.request(
83            "GET", f"/admin/competitions/{competition_id}/registration-summary"
84        )
85        _raise_for_status(resp)
86        return RegistrationSummary.model_validate(resp.json())

Per-verdict totals for the whole competition.

What the roster's filter tabs are labelled with — counting a page would report the page size. Carries no personal data, so unlike roster() it is not audited.

def review( self, competition_id: str, team_id: str, *, status: str, note: str = '') -> ctfy.server.models.RegistrationTeamRow:
 88    def review(
 89        self, competition_id: str, team_id: str, *, status: str, note: str = ""
 90    ) -> RegistrationTeamRow:
 91        """Rule on one squad's eligibility.
 92
 93        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
 94        rejection requires a ``note`` — the server rejects an empty one,
 95        because "rejected, no reason given" becomes a support ticket every
 96        time. Members are emailed the verdict when email is configured.
 97        """
 98        resp = self._http.request(
 99            "POST",
100            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
101            json={"status": status, "note": note},
102        )
103        _raise_for_status(resp)
104        return RegistrationTeamRow.model_validate(resp.json())

Rule on one squad's eligibility.

status is approved / rejected / pending. A rejection requires a note — the server rejects an empty one, because "rejected, no reason given" becomes a support ticket every time. Members are emailed the verdict when email is configured.

def edit( self, competition_id: str, user_id: str, *, answers: ctfy.server.models.RegistrationSubmitRequest) -> ctfy.server.models.RegistrationTeamRow:
106    def edit(
107        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
108    ) -> RegistrationTeamRow:
109        """Correct one entrant's dossier on their behalf.
110
111        For the two cases the entrant's own form cannot serve: a detail
112        taken down wrong that only the organiser can see is wrong, and a
113        registration completed over the phone or at a desk.
114
115        **Not gated on the registration deadline** — correcting a record
116        is not entering the competition, and a typo that becomes
117        uncorrectable the moment sign-up closes is uncorrectable for
118        exactly the period the roster gets read. Lands a
119        ``registration_edited`` audit row naming the caller, the subject
120        and which fields moved (never their values).
121        """
122        resp = self._http.request(
123            "PUT",
124            f"/admin/competitions/{competition_id}/registrations/{user_id}",
125            json=answers.model_dump(mode="json"),
126        )
127        _raise_for_status(resp)
128        return RegistrationTeamRow.model_validate(resp.json())

Correct one entrant's dossier on their behalf.

For the two cases the entrant's own form cannot serve: a detail taken down wrong that only the organiser can see is wrong, and a registration completed over the phone or at a desk.

Not gated on the registration deadline — correcting a record is not entering the competition, and a typo that becomes uncorrectable the moment sign-up closes is uncorrectable for exactly the period the roster gets read. Lands a registration_edited audit row naming the caller, the subject and which fields moved (never their values).

def export_csv(self, competition_id: str) -> bytes:
130    def export_csv(self, competition_id: str) -> bytes:
131        """The organiser's 基本信息 sheet as CSV bytes.
132
133        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
134        correctly. Write the bytes verbatim — re-encoding through ``str``
135        will usually drop the BOM and reintroduce the mojibake.
136        """
137        resp = self._http.request(
138            "GET", f"/admin/competitions/{competition_id}/registration-export"
139        )
140        _raise_for_status(resp)
141        return resp.content

The organiser's 基本信息 sheet as CSV bytes.

UTF-8 with BOM so Excel on Windows decodes Chinese names correctly. Write the bytes verbatim — re-encoding through str will usually drop the BOM and reintroduce the mojibake.