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
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
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.
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.
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.
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.
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).
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.