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
 12from typing import Literal
 13
 14from ctfy.sdk._helpers import _raise_for_status
 15from ctfy.sdk.base import BaseHttpClient
 16from ctfy.server.models import (
 17    BulkApproveRequest,
 18    BulkApproveResponse,
 19    BulkApproveTeam,
 20    EligibilityImpact,
 21    RegistrationConditionsPreview,
 22    RegistrationIdentityEvidence,
 23    RegistrationMessageTemplate,
 24    RegistrationMessageTemplates,
 25    RegistrationMessageTemplateWrite,
 26    RegistrationReviewNote,
 27    RegistrationReviewNoteRequest,
 28    RegistrationReviewNotes,
 29    RegistrationSubmitRequest,
 30    RegistrationSummary,
 31    RegistrationTeamDetail,
 32    RegistrationTeamRow,
 33    RenderedReviewMessage,
 34    ReviewPresence,
 35    ReviewPresenceBeat,
 36    ReviewTimeline,
 37    ReviewTodoItemRequest,
 38    WaitlistBackfillRequest,
 39    WaitlistBackfillResult,
 40)
 41
 42
 43class AdminRegistrationsResource:
 44    def __init__(self, http: BaseHttpClient) -> None:
 45        self._http = http
 46
 47    def roster(
 48        self,
 49        competition_id: str,
 50        *,
 51        status: str = "",
 52        eligibility: str = "",
 53        q: str = "",
 54        sort: str = "",
 55        waitlist: bool = False,
 56        offset: int = 0,
 57        limit: int = 50,
 58    ) -> builtins.list[RegistrationTeamRow]:
 59        """One page of the roster with entrant details, grouped by team.
 60
 61        Paged and verdict-filtered server-side: each dossier costs an
 62        AES-GCM open per sealed field, so a thousand-entrant competition
 63        cannot afford "fetch everything and filter here" — which is
 64        exactly what this used to do.
 65
 66        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
 67        means every verdict. ``eligibility`` is ``verified`` / ``invited`` /
 68        ``lapsed`` and keeps the squads with at least one member so marked
 69        (#2184; empty = every squad). ``q`` searches team name, player id,
 70        nickname, e-mail and real name (#2518); ``sort`` is ``created``
 71        (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order,
 72        then the page, all over the whole competition. ``waitlist`` keeps
 73        only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all`
 74        to walk the whole roster. Each call is audited as a PII read.
 75        """
 76        params: dict[str, object] = {"offset": offset, "limit": limit}
 77        if status:
 78            params["status"] = status
 79        if eligibility:
 80            params["eligibility"] = eligibility
 81        if q:
 82            params["q"] = q
 83        if sort:
 84            params["sort"] = sort
 85        if waitlist:
 86            params["waitlist"] = True
 87        resp = self._http.request(
 88            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
 89        )
 90        _raise_for_status(resp)
 91        return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]
 92
 93    def roster_all(
 94        self, competition_id: str, *, status: str = "", page_size: int = 200
 95    ) -> builtins.list[RegistrationTeamRow]:
 96        """Every page, concatenated — for scripts that genuinely want all.
 97
 98        Kept explicit rather than making :meth:`roster` unbounded, so
 99        "walk the whole roster" is a decision a caller makes rather than
100        the default cost of asking for any of it. Note each page lands
101        its own audit row, which is correct: this reads every entrant's
102        personal data.
103        """
104        out: builtins.list[RegistrationTeamRow] = []
105        offset = 0
106        while True:
107            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
108            out.extend(page)
109            if len(page) < page_size:
110                return out
111            offset += page_size
112
113    def summary(self, competition_id: str) -> RegistrationSummary:
114        """Per-verdict totals for the whole competition.
115
116        What the roster's filter tabs are labelled with — counting a page
117        would report the page size. Carries no personal data, so unlike
118        :meth:`roster` it is not audited.
119        """
120        resp = self._http.request(
121            "GET", f"/admin/competitions/{competition_id}/registration-summary"
122        )
123        _raise_for_status(resp)
124        return RegistrationSummary.model_validate(resp.json())
125
126    def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult:
127        """Move waitlisted teams onto the free places, in queue order (#2767).
128
129        ``limit`` caps how many move (``0`` = as many as are free). The
130        server counts and promotes in one statement, so two reviewers
131        backfilling at once never overfill. Promoted teams stay ``pending``.
132        """
133        resp = self._http.request(
134            "POST",
135            f"/admin/competitions/{competition_id}/registration-waitlist/backfill",
136            json=WaitlistBackfillRequest(limit=limit).model_dump(),
137        )
138        _raise_for_status(resp)
139        return WaitlistBackfillResult.model_validate(resp.json())
140
141    def eligibility_impact(
142        self, competition_id: str, *, domains: builtins.list[str] | None = None
143    ) -> EligibilityImpact:
144        """What an institution rule means for the people already registered (#2184).
145
146        Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is
147        not a PII read. ``domains`` previews a set instead of the stored one.
148        """
149        resp = self._http.request(
150            "GET",
151            f"/admin/competitions/{competition_id}/eligibility-impact",
152            params={"domain": domains} if domains else None,
153        )
154        _raise_for_status(resp)
155        return EligibilityImpact.model_validate(resp.json())
156
157    def conditions_preview(
158        self,
159        competition_id: str,
160        *,
161        domains: builtins.list[str] | None = None,
162        eligibility_mode: Literal["domains", "mainland_student"] | None = None,
163        carsi_roles: builtins.list[str] | None = None,
164        students_only: bool | None = None,
165        team_entity_policy: Literal["none", "same_institution"] | None = None,
166        min_team_size: int | None = None,
167        max_team_size: int | None = None,
168        exempt_invited: bool | None = None,
169        on_ineligible: Literal["refuse", "review"] | None = None,
170        email_satisfies_role: bool | None = None,
171    ) -> RegistrationConditionsPreview:
172        """Which registered teams unsaved registration conditions would flag (#2767).
173
174        Each argument overrides the stored setting; ``None`` keeps it, and
175        ``domains=[]`` previews "no institution list". Flagged teams are
176        never removed. Names members by display name only, so not a PII read.
177        """
178        params: dict[str, object] = {}
179        if domains is not None:
180            params["domain"] = domains or [""]
181        if eligibility_mode is not None:
182            params["eligibility_mode"] = eligibility_mode
183        if carsi_roles is not None:
184            params["carsi_role"] = carsi_roles
185        if students_only is not None:
186            params["students_only"] = students_only
187        if team_entity_policy is not None:
188            params["team_entity_policy"] = team_entity_policy
189        if min_team_size is not None:
190            params["min_team_size"] = min_team_size
191        if max_team_size is not None:
192            params["max_team_size"] = max_team_size
193        if exempt_invited is not None:
194            params["exempt_invited"] = exempt_invited
195        if on_ineligible is not None:
196            params["on_ineligible"] = on_ineligible
197        if email_satisfies_role is not None:
198            params["email_satisfies_role"] = email_satisfies_role
199        resp = self._http.request(
200            "GET",
201            f"/admin/competitions/{competition_id}/registration-conditions/preview",
202            params=params or None,
203        )
204        _raise_for_status(resp)
205        return RegistrationConditionsPreview.model_validate(resp.json())
206
207    def review(
208        self,
209        competition_id: str,
210        team_id: str,
211        *,
212        status: str,
213        note: str = "",
214        review_token: str = "",
215        items: builtins.list[ReviewTodoItemRequest] | None = None,
216    ) -> RegistrationTeamRow:
217        """Rule on one squad's eligibility.
218
219        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
220        rejection requires a ``note`` — the server rejects an empty one,
221        because "rejected, no reason given" becomes a support ticket every
222        time. Members are emailed the verdict when email is configured.
223
224        ``review_token`` is the roster row's (#2521): with it the verdict
225        lands only if the team has not changed since that row was read,
226        else the server answers ``409 review_stale``. Without it the
227        verdict is written unconditionally.
228
229        ``items`` (rejections only, #2527) become one to-do each for the
230        member they name; the last one done resubmits the team.
231        """
232        body: dict[str, object] = {"status": status, "note": note}
233        if review_token:
234            body["review_token"] = review_token
235        if items:
236            body["items"] = [i.model_dump(mode="json") for i in items]
237        resp = self._http.request(
238            "POST",
239            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
240            json=body,
241        )
242        _raise_for_status(resp)
243        return RegistrationTeamRow.model_validate(resp.json())
244
245    def bulk_approve(
246        self, competition_id: str, teams: builtins.list[tuple[str, str]]
247    ) -> BulkApproveResponse:
248        """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521).
249
250        The server decides per team: one that is not pending, was resubmitted,
251        gained a member, was edited after review, is incomplete, has a member
252        who no longer meets the institution rule, or whose token is stale is
253        skipped and its ``skipped_for`` says why. Read the tokens off
254        :meth:`roster`.
255        """
256        body = BulkApproveRequest(
257            teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams]
258        )
259        resp = self._http.request(
260            "POST",
261            f"/admin/competitions/{competition_id}/registration-review/bulk-approve",
262            json=body.model_dump(mode="json"),
263        )
264        _raise_for_status(resp)
265        return BulkApproveResponse.model_validate(resp.json())
266
267    def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence:
268        """Say which team this reviewer has open (``""`` = none) and hear which
269        teams the other reviewers have open right now (#2536).
270
271        A soft hint, never a lock: a sighting lapses after the presence TTL
272        without another beat, and nothing here refuses a verdict -- the
273        review token still does that. The caller's own presence is never in
274        the answer.
275        """
276        body = ReviewPresenceBeat(team_id=team_id)
277        resp = self._http.request(
278            "POST",
279            f"/admin/competitions/{competition_id}/registration-review/presence",
280            json=body.model_dump(mode="json"),
281        )
282        _raise_for_status(resp)
283        return ReviewPresence.model_validate(resp.json())
284
285    def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline:
286        """One team's review history with every edit's values before and after,
287        the changes since the last verdict and the reject to-dos (#2524).
288        Audited as a PII read (``review_timeline``) on every call."""
289        resp = self._http.request(
290            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline"
291        )
292        _raise_for_status(resp)
293        return ReviewTimeline.model_validate(resp.json())
294
295    def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence:
296        """Read all bound emails and CARSI evidence for one reviewed team.
297
298        The server gates and audits this on-demand PII read. Provider ids and
299        tokens are deliberately absent from the returned evidence.
300        """
301        resp = self._http.request(
302            "GET",
303            f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence",
304        )
305        _raise_for_status(resp)
306        return RegistrationIdentityEvidence.model_validate(resp.json())
307
308    def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail:
309        """One team's roster row at its own URL, beside the fields the event
310        collects (#2767). Audited as a PII read (``team_detail``) whenever the
311        event collects sensitive fields, exactly as the roster page it is one
312        row of."""
313        resp = self._http.request(
314            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration"
315        )
316        _raise_for_status(resp)
317        return RegistrationTeamDetail.model_validate(resp.json())
318
319    def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes:
320        """The reviewers' private notes on one team, oldest first (#2767).
321        The team never sees them; every read is audited (``review_notes``)."""
322        resp = self._http.request(
323            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes"
324        )
325        _raise_for_status(resp)
326        return RegistrationReviewNotes.model_validate(resp.json())
327
328    def add_review_note(
329        self, competition_id: str, team_id: str, body: str
330    ) -> RegistrationReviewNote:
331        """Append one reviewer note to a team (#2767). Append-only: there is
332        no edit and no delete. ``body`` is trimmed server-side and must not
333        be empty."""
334        resp = self._http.request(
335            "POST",
336            f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes",
337            json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"),
338        )
339        _raise_for_status(resp)
340        return RegistrationReviewNote.model_validate(resp.json())
341
342    def edit(
343        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
344    ) -> RegistrationTeamRow:
345        """Correct one entrant's dossier on their behalf.
346
347        For the two cases the entrant's own form cannot serve: a detail
348        taken down wrong that only the organiser can see is wrong, and a
349        registration completed over the phone or at a desk.
350
351        **Not gated on the registration deadline** — correcting a record
352        is not entering the competition, and a typo that becomes
353        uncorrectable the moment sign-up closes is uncorrectable for
354        exactly the period the roster gets read. Lands a
355        ``registration_edited`` audit row naming the caller, the subject
356        and which fields moved (never their values).
357        """
358        resp = self._http.request(
359            "PUT",
360            f"/admin/competitions/{competition_id}/registrations/{user_id}",
361            json=answers.model_dump(mode="json"),
362        )
363        _raise_for_status(resp)
364        return RegistrationTeamRow.model_validate(resp.json())
365
366    def export_csv(self, competition_id: str) -> bytes:
367        """The organiser's 基本信息 sheet as CSV bytes.
368
369        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
370        correctly. Write the bytes verbatim — re-encoding through ``str``
371        will usually drop the BOM and reintroduce the mojibake.
372        """
373        resp = self._http.request(
374            "GET", f"/admin/competitions/{competition_id}/registration-export"
375        )
376        _raise_for_status(resp)
377        return resp.content
378
379    # -- rejection wordings (#2746) ------------------------------------------
380
381    def review_templates(self, competition_id: str) -> RegistrationMessageTemplates:
382        """The competition's rejection wordings, oldest first, and the
383        placeholders a body may use. Reviewers and organisers."""
384        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates")
385        _raise_for_status(resp)
386        return RegistrationMessageTemplates.model_validate(resp.json())
387
388    def add_review_template(
389        self, competition_id: str, *, label: dict[str, str], body: dict[str, str]
390    ) -> RegistrationMessageTemplate:
391        """Add one wording; ``label`` and ``body`` are ``{locale: text}``.
392        Organisers only. A body naming a placeholder outside the closed set
393        is refused ``422 review_template_placeholder``."""
394        resp = self._http.request(
395            "POST",
396            f"/admin/competitions/{competition_id}/review-templates",
397            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
398        )
399        _raise_for_status(resp)
400        return RegistrationMessageTemplate.model_validate(resp.json())
401
402    def update_review_template(
403        self,
404        competition_id: str,
405        template_id: str,
406        *,
407        label: dict[str, str],
408        body: dict[str, str],
409    ) -> RegistrationMessageTemplate:
410        """Replace one wording. A rejection already sent keeps its text."""
411        resp = self._http.request(
412            "PUT",
413            f"/admin/competitions/{competition_id}/review-templates/{template_id}",
414            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
415        )
416        _raise_for_status(resp)
417        return RegistrationMessageTemplate.model_validate(resp.json())
418
419    def delete_review_template(
420        self, competition_id: str, template_id: str
421    ) -> RegistrationMessageTemplates:
422        """Remove one wording; returns the set as it now stands."""
423        resp = self._http.request(
424            "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}"
425        )
426        _raise_for_status(resp)
427        return RegistrationMessageTemplates.model_validate(resp.json())
428
429    def render_review_template(
430        self, competition_id: str, team_id: str, template_id: str
431    ) -> RenderedReviewMessage:
432        """One wording filled in for one team, in its captain's mail language."""
433        resp = self._http.request(
434            "GET",
435            f"/admin/competitions/{competition_id}/teams/{team_id}"
436            f"/review-templates/{template_id}/render",
437        )
438        _raise_for_status(resp)
439        return RenderedReviewMessage.model_validate(resp.json())
class AdminRegistrationsResource:
 44class AdminRegistrationsResource:
 45    def __init__(self, http: BaseHttpClient) -> None:
 46        self._http = http
 47
 48    def roster(
 49        self,
 50        competition_id: str,
 51        *,
 52        status: str = "",
 53        eligibility: str = "",
 54        q: str = "",
 55        sort: str = "",
 56        waitlist: bool = False,
 57        offset: int = 0,
 58        limit: int = 50,
 59    ) -> builtins.list[RegistrationTeamRow]:
 60        """One page of the roster with entrant details, grouped by team.
 61
 62        Paged and verdict-filtered server-side: each dossier costs an
 63        AES-GCM open per sealed field, so a thousand-entrant competition
 64        cannot afford "fetch everything and filter here" — which is
 65        exactly what this used to do.
 66
 67        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
 68        means every verdict. ``eligibility`` is ``verified`` / ``invited`` /
 69        ``lapsed`` and keeps the squads with at least one member so marked
 70        (#2184; empty = every squad). ``q`` searches team name, player id,
 71        nickname, e-mail and real name (#2518); ``sort`` is ``created``
 72        (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order,
 73        then the page, all over the whole competition. ``waitlist`` keeps
 74        only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all`
 75        to walk the whole roster. Each call is audited as a PII read.
 76        """
 77        params: dict[str, object] = {"offset": offset, "limit": limit}
 78        if status:
 79            params["status"] = status
 80        if eligibility:
 81            params["eligibility"] = eligibility
 82        if q:
 83            params["q"] = q
 84        if sort:
 85            params["sort"] = sort
 86        if waitlist:
 87            params["waitlist"] = True
 88        resp = self._http.request(
 89            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
 90        )
 91        _raise_for_status(resp)
 92        return [RegistrationTeamRow.model_validate(row) for row in resp.json()["items"]]
 93
 94    def roster_all(
 95        self, competition_id: str, *, status: str = "", page_size: int = 200
 96    ) -> builtins.list[RegistrationTeamRow]:
 97        """Every page, concatenated — for scripts that genuinely want all.
 98
 99        Kept explicit rather than making :meth:`roster` unbounded, so
100        "walk the whole roster" is a decision a caller makes rather than
101        the default cost of asking for any of it. Note each page lands
102        its own audit row, which is correct: this reads every entrant's
103        personal data.
104        """
105        out: builtins.list[RegistrationTeamRow] = []
106        offset = 0
107        while True:
108            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
109            out.extend(page)
110            if len(page) < page_size:
111                return out
112            offset += page_size
113
114    def summary(self, competition_id: str) -> RegistrationSummary:
115        """Per-verdict totals for the whole competition.
116
117        What the roster's filter tabs are labelled with — counting a page
118        would report the page size. Carries no personal data, so unlike
119        :meth:`roster` it is not audited.
120        """
121        resp = self._http.request(
122            "GET", f"/admin/competitions/{competition_id}/registration-summary"
123        )
124        _raise_for_status(resp)
125        return RegistrationSummary.model_validate(resp.json())
126
127    def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult:
128        """Move waitlisted teams onto the free places, in queue order (#2767).
129
130        ``limit`` caps how many move (``0`` = as many as are free). The
131        server counts and promotes in one statement, so two reviewers
132        backfilling at once never overfill. Promoted teams stay ``pending``.
133        """
134        resp = self._http.request(
135            "POST",
136            f"/admin/competitions/{competition_id}/registration-waitlist/backfill",
137            json=WaitlistBackfillRequest(limit=limit).model_dump(),
138        )
139        _raise_for_status(resp)
140        return WaitlistBackfillResult.model_validate(resp.json())
141
142    def eligibility_impact(
143        self, competition_id: str, *, domains: builtins.list[str] | None = None
144    ) -> EligibilityImpact:
145        """What an institution rule means for the people already registered (#2184).
146
147        Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is
148        not a PII read. ``domains`` previews a set instead of the stored one.
149        """
150        resp = self._http.request(
151            "GET",
152            f"/admin/competitions/{competition_id}/eligibility-impact",
153            params={"domain": domains} if domains else None,
154        )
155        _raise_for_status(resp)
156        return EligibilityImpact.model_validate(resp.json())
157
158    def conditions_preview(
159        self,
160        competition_id: str,
161        *,
162        domains: builtins.list[str] | None = None,
163        eligibility_mode: Literal["domains", "mainland_student"] | None = None,
164        carsi_roles: builtins.list[str] | None = None,
165        students_only: bool | None = None,
166        team_entity_policy: Literal["none", "same_institution"] | None = None,
167        min_team_size: int | None = None,
168        max_team_size: int | None = None,
169        exempt_invited: bool | None = None,
170        on_ineligible: Literal["refuse", "review"] | None = None,
171        email_satisfies_role: bool | None = None,
172    ) -> RegistrationConditionsPreview:
173        """Which registered teams unsaved registration conditions would flag (#2767).
174
175        Each argument overrides the stored setting; ``None`` keeps it, and
176        ``domains=[]`` previews "no institution list". Flagged teams are
177        never removed. Names members by display name only, so not a PII read.
178        """
179        params: dict[str, object] = {}
180        if domains is not None:
181            params["domain"] = domains or [""]
182        if eligibility_mode is not None:
183            params["eligibility_mode"] = eligibility_mode
184        if carsi_roles is not None:
185            params["carsi_role"] = carsi_roles
186        if students_only is not None:
187            params["students_only"] = students_only
188        if team_entity_policy is not None:
189            params["team_entity_policy"] = team_entity_policy
190        if min_team_size is not None:
191            params["min_team_size"] = min_team_size
192        if max_team_size is not None:
193            params["max_team_size"] = max_team_size
194        if exempt_invited is not None:
195            params["exempt_invited"] = exempt_invited
196        if on_ineligible is not None:
197            params["on_ineligible"] = on_ineligible
198        if email_satisfies_role is not None:
199            params["email_satisfies_role"] = email_satisfies_role
200        resp = self._http.request(
201            "GET",
202            f"/admin/competitions/{competition_id}/registration-conditions/preview",
203            params=params or None,
204        )
205        _raise_for_status(resp)
206        return RegistrationConditionsPreview.model_validate(resp.json())
207
208    def review(
209        self,
210        competition_id: str,
211        team_id: str,
212        *,
213        status: str,
214        note: str = "",
215        review_token: str = "",
216        items: builtins.list[ReviewTodoItemRequest] | None = None,
217    ) -> RegistrationTeamRow:
218        """Rule on one squad's eligibility.
219
220        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
221        rejection requires a ``note`` — the server rejects an empty one,
222        because "rejected, no reason given" becomes a support ticket every
223        time. Members are emailed the verdict when email is configured.
224
225        ``review_token`` is the roster row's (#2521): with it the verdict
226        lands only if the team has not changed since that row was read,
227        else the server answers ``409 review_stale``. Without it the
228        verdict is written unconditionally.
229
230        ``items`` (rejections only, #2527) become one to-do each for the
231        member they name; the last one done resubmits the team.
232        """
233        body: dict[str, object] = {"status": status, "note": note}
234        if review_token:
235            body["review_token"] = review_token
236        if items:
237            body["items"] = [i.model_dump(mode="json") for i in items]
238        resp = self._http.request(
239            "POST",
240            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
241            json=body,
242        )
243        _raise_for_status(resp)
244        return RegistrationTeamRow.model_validate(resp.json())
245
246    def bulk_approve(
247        self, competition_id: str, teams: builtins.list[tuple[str, str]]
248    ) -> BulkApproveResponse:
249        """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521).
250
251        The server decides per team: one that is not pending, was resubmitted,
252        gained a member, was edited after review, is incomplete, has a member
253        who no longer meets the institution rule, or whose token is stale is
254        skipped and its ``skipped_for`` says why. Read the tokens off
255        :meth:`roster`.
256        """
257        body = BulkApproveRequest(
258            teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams]
259        )
260        resp = self._http.request(
261            "POST",
262            f"/admin/competitions/{competition_id}/registration-review/bulk-approve",
263            json=body.model_dump(mode="json"),
264        )
265        _raise_for_status(resp)
266        return BulkApproveResponse.model_validate(resp.json())
267
268    def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence:
269        """Say which team this reviewer has open (``""`` = none) and hear which
270        teams the other reviewers have open right now (#2536).
271
272        A soft hint, never a lock: a sighting lapses after the presence TTL
273        without another beat, and nothing here refuses a verdict -- the
274        review token still does that. The caller's own presence is never in
275        the answer.
276        """
277        body = ReviewPresenceBeat(team_id=team_id)
278        resp = self._http.request(
279            "POST",
280            f"/admin/competitions/{competition_id}/registration-review/presence",
281            json=body.model_dump(mode="json"),
282        )
283        _raise_for_status(resp)
284        return ReviewPresence.model_validate(resp.json())
285
286    def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline:
287        """One team's review history with every edit's values before and after,
288        the changes since the last verdict and the reject to-dos (#2524).
289        Audited as a PII read (``review_timeline``) on every call."""
290        resp = self._http.request(
291            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline"
292        )
293        _raise_for_status(resp)
294        return ReviewTimeline.model_validate(resp.json())
295
296    def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence:
297        """Read all bound emails and CARSI evidence for one reviewed team.
298
299        The server gates and audits this on-demand PII read. Provider ids and
300        tokens are deliberately absent from the returned evidence.
301        """
302        resp = self._http.request(
303            "GET",
304            f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence",
305        )
306        _raise_for_status(resp)
307        return RegistrationIdentityEvidence.model_validate(resp.json())
308
309    def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail:
310        """One team's roster row at its own URL, beside the fields the event
311        collects (#2767). Audited as a PII read (``team_detail``) whenever the
312        event collects sensitive fields, exactly as the roster page it is one
313        row of."""
314        resp = self._http.request(
315            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration"
316        )
317        _raise_for_status(resp)
318        return RegistrationTeamDetail.model_validate(resp.json())
319
320    def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes:
321        """The reviewers' private notes on one team, oldest first (#2767).
322        The team never sees them; every read is audited (``review_notes``)."""
323        resp = self._http.request(
324            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes"
325        )
326        _raise_for_status(resp)
327        return RegistrationReviewNotes.model_validate(resp.json())
328
329    def add_review_note(
330        self, competition_id: str, team_id: str, body: str
331    ) -> RegistrationReviewNote:
332        """Append one reviewer note to a team (#2767). Append-only: there is
333        no edit and no delete. ``body`` is trimmed server-side and must not
334        be empty."""
335        resp = self._http.request(
336            "POST",
337            f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes",
338            json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"),
339        )
340        _raise_for_status(resp)
341        return RegistrationReviewNote.model_validate(resp.json())
342
343    def edit(
344        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
345    ) -> RegistrationTeamRow:
346        """Correct one entrant's dossier on their behalf.
347
348        For the two cases the entrant's own form cannot serve: a detail
349        taken down wrong that only the organiser can see is wrong, and a
350        registration completed over the phone or at a desk.
351
352        **Not gated on the registration deadline** — correcting a record
353        is not entering the competition, and a typo that becomes
354        uncorrectable the moment sign-up closes is uncorrectable for
355        exactly the period the roster gets read. Lands a
356        ``registration_edited`` audit row naming the caller, the subject
357        and which fields moved (never their values).
358        """
359        resp = self._http.request(
360            "PUT",
361            f"/admin/competitions/{competition_id}/registrations/{user_id}",
362            json=answers.model_dump(mode="json"),
363        )
364        _raise_for_status(resp)
365        return RegistrationTeamRow.model_validate(resp.json())
366
367    def export_csv(self, competition_id: str) -> bytes:
368        """The organiser's 基本信息 sheet as CSV bytes.
369
370        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
371        correctly. Write the bytes verbatim — re-encoding through ``str``
372        will usually drop the BOM and reintroduce the mojibake.
373        """
374        resp = self._http.request(
375            "GET", f"/admin/competitions/{competition_id}/registration-export"
376        )
377        _raise_for_status(resp)
378        return resp.content
379
380    # -- rejection wordings (#2746) ------------------------------------------
381
382    def review_templates(self, competition_id: str) -> RegistrationMessageTemplates:
383        """The competition's rejection wordings, oldest first, and the
384        placeholders a body may use. Reviewers and organisers."""
385        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates")
386        _raise_for_status(resp)
387        return RegistrationMessageTemplates.model_validate(resp.json())
388
389    def add_review_template(
390        self, competition_id: str, *, label: dict[str, str], body: dict[str, str]
391    ) -> RegistrationMessageTemplate:
392        """Add one wording; ``label`` and ``body`` are ``{locale: text}``.
393        Organisers only. A body naming a placeholder outside the closed set
394        is refused ``422 review_template_placeholder``."""
395        resp = self._http.request(
396            "POST",
397            f"/admin/competitions/{competition_id}/review-templates",
398            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
399        )
400        _raise_for_status(resp)
401        return RegistrationMessageTemplate.model_validate(resp.json())
402
403    def update_review_template(
404        self,
405        competition_id: str,
406        template_id: str,
407        *,
408        label: dict[str, str],
409        body: dict[str, str],
410    ) -> RegistrationMessageTemplate:
411        """Replace one wording. A rejection already sent keeps its text."""
412        resp = self._http.request(
413            "PUT",
414            f"/admin/competitions/{competition_id}/review-templates/{template_id}",
415            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
416        )
417        _raise_for_status(resp)
418        return RegistrationMessageTemplate.model_validate(resp.json())
419
420    def delete_review_template(
421        self, competition_id: str, template_id: str
422    ) -> RegistrationMessageTemplates:
423        """Remove one wording; returns the set as it now stands."""
424        resp = self._http.request(
425            "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}"
426        )
427        _raise_for_status(resp)
428        return RegistrationMessageTemplates.model_validate(resp.json())
429
430    def render_review_template(
431        self, competition_id: str, team_id: str, template_id: str
432    ) -> RenderedReviewMessage:
433        """One wording filled in for one team, in its captain's mail language."""
434        resp = self._http.request(
435            "GET",
436            f"/admin/competitions/{competition_id}/teams/{team_id}"
437            f"/review-templates/{template_id}/render",
438        )
439        _raise_for_status(resp)
440        return RenderedReviewMessage.model_validate(resp.json())
AdminRegistrationsResource(http: ctfy.sdk.base.BaseHttpClient)
45    def __init__(self, http: BaseHttpClient) -> None:
46        self._http = http
def roster( self, competition_id: str, *, status: str = '', eligibility: str = '', q: str = '', sort: str = '', waitlist: bool = False, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.RegistrationTeamRow]:
48    def roster(
49        self,
50        competition_id: str,
51        *,
52        status: str = "",
53        eligibility: str = "",
54        q: str = "",
55        sort: str = "",
56        waitlist: bool = False,
57        offset: int = 0,
58        limit: int = 50,
59    ) -> builtins.list[RegistrationTeamRow]:
60        """One page of the roster with entrant details, grouped by team.
61
62        Paged and verdict-filtered server-side: each dossier costs an
63        AES-GCM open per sealed field, so a thousand-entrant competition
64        cannot afford "fetch everything and filter here" — which is
65        exactly what this used to do.
66
67        ``status`` is ``pending`` / ``approved`` / ``rejected``; empty
68        means every verdict. ``eligibility`` is ``verified`` / ``invited`` /
69        ``lapsed`` and keeps the squads with at least one member so marked
70        (#2184; empty = every squad). ``q`` searches team name, player id,
71        nickname, e-mail and real name (#2518); ``sort`` is ``created``
72        (default) / ``waiting`` / ``recent`` (#2524). Filters, then the order,
73        then the page, all over the whole competition. ``waitlist`` keeps
74        only the waitlisted teams, in queue order (#2767). Use :meth:`roster_all`
75        to walk the whole roster. Each call is audited as a PII read.
76        """
77        params: dict[str, object] = {"offset": offset, "limit": limit}
78        if status:
79            params["status"] = status
80        if eligibility:
81            params["eligibility"] = eligibility
82        if q:
83            params["q"] = q
84        if sort:
85            params["sort"] = sort
86        if waitlist:
87            params["waitlist"] = True
88        resp = self._http.request(
89            "GET", f"/admin/competitions/{competition_id}/registration-roster", params=params
90        )
91        _raise_for_status(resp)
92        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. eligibility is verified / invited / lapsed and keeps the squads with at least one member so marked (#2184; empty = every squad). q searches team name, player id, nickname, e-mail and real name (#2518); sort is created (default) / waiting / recent (#2524). Filters, then the order, then the page, all over the whole competition. waitlist keeps only the waitlisted teams, in queue order (#2767). 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]:
 94    def roster_all(
 95        self, competition_id: str, *, status: str = "", page_size: int = 200
 96    ) -> builtins.list[RegistrationTeamRow]:
 97        """Every page, concatenated — for scripts that genuinely want all.
 98
 99        Kept explicit rather than making :meth:`roster` unbounded, so
100        "walk the whole roster" is a decision a caller makes rather than
101        the default cost of asking for any of it. Note each page lands
102        its own audit row, which is correct: this reads every entrant's
103        personal data.
104        """
105        out: builtins.list[RegistrationTeamRow] = []
106        offset = 0
107        while True:
108            page = self.roster(competition_id, status=status, offset=offset, limit=page_size)
109            out.extend(page)
110            if len(page) < page_size:
111                return out
112            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:
114    def summary(self, competition_id: str) -> RegistrationSummary:
115        """Per-verdict totals for the whole competition.
116
117        What the roster's filter tabs are labelled with — counting a page
118        would report the page size. Carries no personal data, so unlike
119        :meth:`roster` it is not audited.
120        """
121        resp = self._http.request(
122            "GET", f"/admin/competitions/{competition_id}/registration-summary"
123        )
124        _raise_for_status(resp)
125        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 backfill_waitlist( self, competition_id: str, *, limit: int = 0) -> ctfy.server.models.WaitlistBackfillResult:
127    def backfill_waitlist(self, competition_id: str, *, limit: int = 0) -> WaitlistBackfillResult:
128        """Move waitlisted teams onto the free places, in queue order (#2767).
129
130        ``limit`` caps how many move (``0`` = as many as are free). The
131        server counts and promotes in one statement, so two reviewers
132        backfilling at once never overfill. Promoted teams stay ``pending``.
133        """
134        resp = self._http.request(
135            "POST",
136            f"/admin/competitions/{competition_id}/registration-waitlist/backfill",
137            json=WaitlistBackfillRequest(limit=limit).model_dump(),
138        )
139        _raise_for_status(resp)
140        return WaitlistBackfillResult.model_validate(resp.json())

Move waitlisted teams onto the free places, in queue order (#2767).

limit caps how many move (0 = as many as are free). The server counts and promotes in one statement, so two reviewers backfilling at once never overfill. Promoted teams stay pending.

def eligibility_impact( self, competition_id: str, *, domains: list[str] | None = None) -> ctfy.server.models.EligibilityImpact:
142    def eligibility_impact(
143        self, competition_id: str, *, domains: builtins.list[str] | None = None
144    ) -> EligibilityImpact:
145        """What an institution rule means for the people already registered (#2184).
146
147        Counts only -- ``verified`` / ``invited`` / ``not_meeting`` -- so it is
148        not a PII read. ``domains`` previews a set instead of the stored one.
149        """
150        resp = self._http.request(
151            "GET",
152            f"/admin/competitions/{competition_id}/eligibility-impact",
153            params={"domain": domains} if domains else None,
154        )
155        _raise_for_status(resp)
156        return EligibilityImpact.model_validate(resp.json())

What an institution rule means for the people already registered (#2184).

Counts only -- verified / invited / not_meeting -- so it is not a PII read. domains previews a set instead of the stored one.

def conditions_preview( self, competition_id: str, *, domains: list[str] | None = None, eligibility_mode: Optional[Literal['domains', 'mainland_student']] = None, carsi_roles: list[str] | None = None, students_only: bool | None = None, team_entity_policy: Optional[Literal['none', 'same_institution']] = None, min_team_size: int | None = None, max_team_size: int | None = None, exempt_invited: bool | None = None, on_ineligible: Optional[Literal['refuse', 'review']] = None, email_satisfies_role: bool | None = None) -> ctfy.server.models.RegistrationConditionsPreview:
158    def conditions_preview(
159        self,
160        competition_id: str,
161        *,
162        domains: builtins.list[str] | None = None,
163        eligibility_mode: Literal["domains", "mainland_student"] | None = None,
164        carsi_roles: builtins.list[str] | None = None,
165        students_only: bool | None = None,
166        team_entity_policy: Literal["none", "same_institution"] | None = None,
167        min_team_size: int | None = None,
168        max_team_size: int | None = None,
169        exempt_invited: bool | None = None,
170        on_ineligible: Literal["refuse", "review"] | None = None,
171        email_satisfies_role: bool | None = None,
172    ) -> RegistrationConditionsPreview:
173        """Which registered teams unsaved registration conditions would flag (#2767).
174
175        Each argument overrides the stored setting; ``None`` keeps it, and
176        ``domains=[]`` previews "no institution list". Flagged teams are
177        never removed. Names members by display name only, so not a PII read.
178        """
179        params: dict[str, object] = {}
180        if domains is not None:
181            params["domain"] = domains or [""]
182        if eligibility_mode is not None:
183            params["eligibility_mode"] = eligibility_mode
184        if carsi_roles is not None:
185            params["carsi_role"] = carsi_roles
186        if students_only is not None:
187            params["students_only"] = students_only
188        if team_entity_policy is not None:
189            params["team_entity_policy"] = team_entity_policy
190        if min_team_size is not None:
191            params["min_team_size"] = min_team_size
192        if max_team_size is not None:
193            params["max_team_size"] = max_team_size
194        if exempt_invited is not None:
195            params["exempt_invited"] = exempt_invited
196        if on_ineligible is not None:
197            params["on_ineligible"] = on_ineligible
198        if email_satisfies_role is not None:
199            params["email_satisfies_role"] = email_satisfies_role
200        resp = self._http.request(
201            "GET",
202            f"/admin/competitions/{competition_id}/registration-conditions/preview",
203            params=params or None,
204        )
205        _raise_for_status(resp)
206        return RegistrationConditionsPreview.model_validate(resp.json())

Which registered teams unsaved registration conditions would flag (#2767).

Each argument overrides the stored setting; None keeps it, and domains=[] previews "no institution list". Flagged teams are never removed. Names members by display name only, so not a PII read.

def review( self, competition_id: str, team_id: str, *, status: str, note: str = '', review_token: str = '', items: list[ctfy.server.models.ReviewTodoItemRequest] | None = None) -> ctfy.server.models.RegistrationTeamRow:
208    def review(
209        self,
210        competition_id: str,
211        team_id: str,
212        *,
213        status: str,
214        note: str = "",
215        review_token: str = "",
216        items: builtins.list[ReviewTodoItemRequest] | None = None,
217    ) -> RegistrationTeamRow:
218        """Rule on one squad's eligibility.
219
220        ``status`` is ``approved`` / ``rejected`` / ``pending``. A
221        rejection requires a ``note`` — the server rejects an empty one,
222        because "rejected, no reason given" becomes a support ticket every
223        time. Members are emailed the verdict when email is configured.
224
225        ``review_token`` is the roster row's (#2521): with it the verdict
226        lands only if the team has not changed since that row was read,
227        else the server answers ``409 review_stale``. Without it the
228        verdict is written unconditionally.
229
230        ``items`` (rejections only, #2527) become one to-do each for the
231        member they name; the last one done resubmits the team.
232        """
233        body: dict[str, object] = {"status": status, "note": note}
234        if review_token:
235            body["review_token"] = review_token
236        if items:
237            body["items"] = [i.model_dump(mode="json") for i in items]
238        resp = self._http.request(
239            "POST",
240            f"/admin/competitions/{competition_id}/teams/{team_id}/review",
241            json=body,
242        )
243        _raise_for_status(resp)
244        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.

review_token is the roster row's (#2521): with it the verdict lands only if the team has not changed since that row was read, else the server answers 409 review_stale. Without it the verdict is written unconditionally.

items (rejections only, #2527) become one to-do each for the member they name; the last one done resubmits the team.

def bulk_approve( self, competition_id: str, teams: list[tuple[str, str]]) -> ctfy.server.models.BulkApproveResponse:
246    def bulk_approve(
247        self, competition_id: str, teams: builtins.list[tuple[str, str]]
248    ) -> BulkApproveResponse:
249        """Approve the given ``(team_id, review_token)`` pairs that carry no marker (#2521).
250
251        The server decides per team: one that is not pending, was resubmitted,
252        gained a member, was edited after review, is incomplete, has a member
253        who no longer meets the institution rule, or whose token is stale is
254        skipped and its ``skipped_for`` says why. Read the tokens off
255        :meth:`roster`.
256        """
257        body = BulkApproveRequest(
258            teams=[BulkApproveTeam(team_id=tid, review_token=tok) for tid, tok in teams]
259        )
260        resp = self._http.request(
261            "POST",
262            f"/admin/competitions/{competition_id}/registration-review/bulk-approve",
263            json=body.model_dump(mode="json"),
264        )
265        _raise_for_status(resp)
266        return BulkApproveResponse.model_validate(resp.json())

Approve the given (team_id, review_token) pairs that carry no marker (#2521).

The server decides per team: one that is not pending, was resubmitted, gained a member, was edited after review, is incomplete, has a member who no longer meets the institution rule, or whose token is stale is skipped and its skipped_for says why. Read the tokens off roster().

def review_presence( self, competition_id: str, team_id: str = '') -> ctfy.server.models.ReviewPresence:
268    def review_presence(self, competition_id: str, team_id: str = "") -> ReviewPresence:
269        """Say which team this reviewer has open (``""`` = none) and hear which
270        teams the other reviewers have open right now (#2536).
271
272        A soft hint, never a lock: a sighting lapses after the presence TTL
273        without another beat, and nothing here refuses a verdict -- the
274        review token still does that. The caller's own presence is never in
275        the answer.
276        """
277        body = ReviewPresenceBeat(team_id=team_id)
278        resp = self._http.request(
279            "POST",
280            f"/admin/competitions/{competition_id}/registration-review/presence",
281            json=body.model_dump(mode="json"),
282        )
283        _raise_for_status(resp)
284        return ReviewPresence.model_validate(resp.json())

Say which team this reviewer has open ("" = none) and hear which teams the other reviewers have open right now (#2536).

A soft hint, never a lock: a sighting lapses after the presence TTL without another beat, and nothing here refuses a verdict -- the review token still does that. The caller's own presence is never in the answer.

def review_timeline( self, competition_id: str, team_id: str) -> ctfy.server.models.ReviewTimeline:
286    def review_timeline(self, competition_id: str, team_id: str) -> ReviewTimeline:
287        """One team's review history with every edit's values before and after,
288        the changes since the last verdict and the reject to-dos (#2524).
289        Audited as a PII read (``review_timeline``) on every call."""
290        resp = self._http.request(
291            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-timeline"
292        )
293        _raise_for_status(resp)
294        return ReviewTimeline.model_validate(resp.json())

One team's review history with every edit's values before and after, the changes since the last verdict and the reject to-dos (#2524). Audited as a PII read (review_timeline) on every call.

def identity_evidence( self, competition_id: str, team_id: str) -> ctfy.server.models.RegistrationIdentityEvidence:
296    def identity_evidence(self, competition_id: str, team_id: str) -> RegistrationIdentityEvidence:
297        """Read all bound emails and CARSI evidence for one reviewed team.
298
299        The server gates and audits this on-demand PII read. Provider ids and
300        tokens are deliberately absent from the returned evidence.
301        """
302        resp = self._http.request(
303            "GET",
304            f"/admin/competitions/{competition_id}/teams/{team_id}/identity-evidence",
305        )
306        _raise_for_status(resp)
307        return RegistrationIdentityEvidence.model_validate(resp.json())

Read all bound emails and CARSI evidence for one reviewed team.

The server gates and audits this on-demand PII read. Provider ids and tokens are deliberately absent from the returned evidence.

def team_detail( self, competition_id: str, team_id: str) -> ctfy.server.models.RegistrationTeamDetail:
309    def team_detail(self, competition_id: str, team_id: str) -> RegistrationTeamDetail:
310        """One team's roster row at its own URL, beside the fields the event
311        collects (#2767). Audited as a PII read (``team_detail``) whenever the
312        event collects sensitive fields, exactly as the roster page it is one
313        row of."""
314        resp = self._http.request(
315            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/registration"
316        )
317        _raise_for_status(resp)
318        return RegistrationTeamDetail.model_validate(resp.json())

One team's roster row at its own URL, beside the fields the event collects (#2767). Audited as a PII read (team_detail) whenever the event collects sensitive fields, exactly as the roster page it is one row of.

def review_notes( self, competition_id: str, team_id: str) -> ctfy.server.models.RegistrationReviewNotes:
320    def review_notes(self, competition_id: str, team_id: str) -> RegistrationReviewNotes:
321        """The reviewers' private notes on one team, oldest first (#2767).
322        The team never sees them; every read is audited (``review_notes``)."""
323        resp = self._http.request(
324            "GET", f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes"
325        )
326        _raise_for_status(resp)
327        return RegistrationReviewNotes.model_validate(resp.json())

The reviewers' private notes on one team, oldest first (#2767). The team never sees them; every read is audited (review_notes).

def add_review_note( self, competition_id: str, team_id: str, body: str) -> ctfy.server.models.RegistrationReviewNote:
329    def add_review_note(
330        self, competition_id: str, team_id: str, body: str
331    ) -> RegistrationReviewNote:
332        """Append one reviewer note to a team (#2767). Append-only: there is
333        no edit and no delete. ``body`` is trimmed server-side and must not
334        be empty."""
335        resp = self._http.request(
336            "POST",
337            f"/admin/competitions/{competition_id}/teams/{team_id}/review-notes",
338            json=RegistrationReviewNoteRequest(body=body).model_dump(mode="json"),
339        )
340        _raise_for_status(resp)
341        return RegistrationReviewNote.model_validate(resp.json())

Append one reviewer note to a team (#2767). Append-only: there is no edit and no delete. body is trimmed server-side and must not be empty.

def edit( self, competition_id: str, user_id: str, *, answers: ctfy.server.models.RegistrationSubmitRequest) -> ctfy.server.models.RegistrationTeamRow:
343    def edit(
344        self, competition_id: str, user_id: str, *, answers: RegistrationSubmitRequest
345    ) -> RegistrationTeamRow:
346        """Correct one entrant's dossier on their behalf.
347
348        For the two cases the entrant's own form cannot serve: a detail
349        taken down wrong that only the organiser can see is wrong, and a
350        registration completed over the phone or at a desk.
351
352        **Not gated on the registration deadline** — correcting a record
353        is not entering the competition, and a typo that becomes
354        uncorrectable the moment sign-up closes is uncorrectable for
355        exactly the period the roster gets read. Lands a
356        ``registration_edited`` audit row naming the caller, the subject
357        and which fields moved (never their values).
358        """
359        resp = self._http.request(
360            "PUT",
361            f"/admin/competitions/{competition_id}/registrations/{user_id}",
362            json=answers.model_dump(mode="json"),
363        )
364        _raise_for_status(resp)
365        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:
367    def export_csv(self, competition_id: str) -> bytes:
368        """The organiser's 基本信息 sheet as CSV bytes.
369
370        UTF-8 **with BOM** so Excel on Windows decodes Chinese names
371        correctly. Write the bytes verbatim — re-encoding through ``str``
372        will usually drop the BOM and reintroduce the mojibake.
373        """
374        resp = self._http.request(
375            "GET", f"/admin/competitions/{competition_id}/registration-export"
376        )
377        _raise_for_status(resp)
378        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.

def review_templates( self, competition_id: str) -> ctfy.server.models.RegistrationMessageTemplates:
382    def review_templates(self, competition_id: str) -> RegistrationMessageTemplates:
383        """The competition's rejection wordings, oldest first, and the
384        placeholders a body may use. Reviewers and organisers."""
385        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/review-templates")
386        _raise_for_status(resp)
387        return RegistrationMessageTemplates.model_validate(resp.json())

The competition's rejection wordings, oldest first, and the placeholders a body may use. Reviewers and organisers.

def add_review_template( self, competition_id: str, *, label: dict[str, str], body: dict[str, str]) -> ctfy.server.models.RegistrationMessageTemplate:
389    def add_review_template(
390        self, competition_id: str, *, label: dict[str, str], body: dict[str, str]
391    ) -> RegistrationMessageTemplate:
392        """Add one wording; ``label`` and ``body`` are ``{locale: text}``.
393        Organisers only. A body naming a placeholder outside the closed set
394        is refused ``422 review_template_placeholder``."""
395        resp = self._http.request(
396            "POST",
397            f"/admin/competitions/{competition_id}/review-templates",
398            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
399        )
400        _raise_for_status(resp)
401        return RegistrationMessageTemplate.model_validate(resp.json())

Add one wording; label and body are {locale: text}. Organisers only. A body naming a placeholder outside the closed set is refused 422 review_template_placeholder.

def update_review_template( self, competition_id: str, template_id: str, *, label: dict[str, str], body: dict[str, str]) -> ctfy.server.models.RegistrationMessageTemplate:
403    def update_review_template(
404        self,
405        competition_id: str,
406        template_id: str,
407        *,
408        label: dict[str, str],
409        body: dict[str, str],
410    ) -> RegistrationMessageTemplate:
411        """Replace one wording. A rejection already sent keeps its text."""
412        resp = self._http.request(
413            "PUT",
414            f"/admin/competitions/{competition_id}/review-templates/{template_id}",
415            json=RegistrationMessageTemplateWrite(label=label, body=body).model_dump(mode="json"),
416        )
417        _raise_for_status(resp)
418        return RegistrationMessageTemplate.model_validate(resp.json())

Replace one wording. A rejection already sent keeps its text.

def delete_review_template( self, competition_id: str, template_id: str) -> ctfy.server.models.RegistrationMessageTemplates:
420    def delete_review_template(
421        self, competition_id: str, template_id: str
422    ) -> RegistrationMessageTemplates:
423        """Remove one wording; returns the set as it now stands."""
424        resp = self._http.request(
425            "DELETE", f"/admin/competitions/{competition_id}/review-templates/{template_id}"
426        )
427        _raise_for_status(resp)
428        return RegistrationMessageTemplates.model_validate(resp.json())

Remove one wording; returns the set as it now stands.

def render_review_template( self, competition_id: str, team_id: str, template_id: str) -> ctfy.server.models.RenderedReviewMessage:
430    def render_review_template(
431        self, competition_id: str, team_id: str, template_id: str
432    ) -> RenderedReviewMessage:
433        """One wording filled in for one team, in its captain's mail language."""
434        resp = self._http.request(
435            "GET",
436            f"/admin/competitions/{competition_id}/teams/{team_id}"
437            f"/review-templates/{template_id}/render",
438        )
439        _raise_for_status(resp)
440        return RenderedReviewMessage.model_validate(resp.json())

One wording filled in for one team, in its captain's mail language.