ctfy.sdk.resources.me

client.me — the calling user's profile, inbox, account + progress.

  1"""``client.me`` — the calling user's profile, inbox, account + progress."""
  2
  3from __future__ import annotations
  4
  5from typing import Any
  6
  7from ctfy.sdk._helpers import _raise_for_status
  8from ctfy.sdk.base import BaseHttpClient
  9from ctfy.server.models import (
 10    AccountDeletionAccepted,
 11    EmailVerificationSent,
 12    InboxResponse,
 13    MeResponse,
 14    MilestoneProgress,
 15    MyAchievementsResponse,
 16    MySolveSummary,
 17    NotificationPreferencesResponse,
 18    ReauthenticationOpened,
 19    StarGazerVerifyResponse,
 20)
 21
 22
 23class MeResource:
 24    """Self-service surface for the authenticated caller."""
 25
 26    def __init__(self, http: BaseHttpClient) -> None:
 27        self._http = http
 28
 29    def get(self) -> MeResponse:
 30        """Get the calling user's profile + auth context.
 31
 32        Per-competition team memberships ride along on
 33        ``competition_teams``; there is no global "current team" any
 34        more — pick the row whose ``competition_id`` matches the
 35        scope you care about.
 36        """
 37        resp = self._http.request("GET", "/me")
 38        _raise_for_status(resp)
 39        return MeResponse.model_validate(resp.json())
 40
 41    def inbox(self) -> InboxResponse:
 42        """Pending team invites, join requests, direct invites, announcements."""
 43        resp = self._http.request("GET", "/me/inbox")
 44        _raise_for_status(resp)
 45        return InboxResponse.model_validate(resp.json())
 46
 47    def notification_preferences(self) -> NotificationPreferencesResponse:
 48        """Which categories reach the caller by email.
 49
 50        Each cell reports ``effective`` (what happens now), ``default``
 51        (what reverting would restore) and ``explicit`` (whether the user
 52        actually chose it, or is merely inheriting). Storage is sparse, so
 53        an untouched cell has no row — ``explicit`` is how you tell.
 54        """
 55        resp = self._http.request("GET", "/me/notification-preferences")
 56        _raise_for_status(resp)
 57        return NotificationPreferencesResponse.model_validate(resp.json())
 58
 59    def set_notification_preference(
 60        self, category: str, enabled: bool, *, channel: str = "email"
 61    ) -> NotificationPreferencesResponse:
 62        """Turn one category on or off for one channel.
 63
 64        ``security`` is refused with a 400 — sign-in codes and account
 65        alerts exist to protect the account and are not switchable.
 66        """
 67        resp = self._http.request(
 68            "PATCH",
 69            "/me/notification-preferences",
 70            json={"category": category, "channel": channel, "enabled": enabled},
 71        )
 72        _raise_for_status(resp)
 73        return NotificationPreferencesResponse.model_validate(resp.json())
 74
 75    def clear_notification_preference(
 76        self, category: str, *, channel: str = "email"
 77    ) -> NotificationPreferencesResponse:
 78        """Drop the explicit cell so the default applies again.
 79
 80        This is *revert*, not *disable*: for most categories the default
 81        is on, so clearing an explicit ``False`` turns mail back on.
 82        """
 83        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
 84        _raise_for_status(resp)
 85        return NotificationPreferencesResponse.model_validate(resp.json())
 86
 87    def read_announcement(self, announcement_id: str) -> None:
 88        """Mark one announcement as read for the calling user."""
 89        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
 90        _raise_for_status(resp)
 91
 92    def read_all_announcements(self) -> None:
 93        """Mark every relevant announcement as read."""
 94        resp = self._http.request("POST", "/me/announcements/read-all")
 95        _raise_for_status(resp)
 96
 97    def update_profile(self, **fields: Any) -> MeResponse:
 98        """PATCH the caller's profile.
 99
100        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
101        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
102        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
103
104        Example::
105
106            client.me.update_profile(bio="hacking goblins", country="JP")
107            client.me.update_profile(website_url=None)  # clear it
108        """
109        resp = self._http.request("PATCH", "/me/profile", json=fields)
110        _raise_for_status(resp)
111        return MeResponse.model_validate(resp.json())
112
113    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
114        """PATCH per-field privacy flags on the caller's profile.
115
116        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
117        server-side. Returns the updated :class:`MeResponse`.
118        """
119        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
120        _raise_for_status(resp)
121        return MeResponse.model_validate(resp.json())
122
123    def export_account(self) -> bytes:
124        """Download a ZIP archive of every piece of data the platform
125        holds about the caller (user, identities, tokens, memberships,
126        submissions, solves, activity). Returns raw bytes — write to a
127        file with ``Path(...).write_bytes(client.me.export_account())``."""
128        resp = self._http.request("GET", "/me/export")
129        _raise_for_status(resp)
130        return resp.content
131
132    def reauthenticate(
133        self,
134        *,
135        current_password: str = "",
136        code: str = "",
137    ) -> ReauthenticationOpened:
138        """Prove a factor again, opening the sudo window on **this** token.
139
140        A handful of account changes — repointing the primary address,
141        setting or removing a password, unlinking an identity, minting a
142        token, widening one — refuse with ``409
143        reauthentication_required`` unless the credential making the
144        request proved a factor inside the last
145        ``reauth_window_s`` seconds. This is how it proves one.
146
147        Signing in already counts, so a freshly-minted session needs
148        nothing; this is for a bearer that has been held past the
149        window. Read ``deletion_proof`` off :meth:`get` to learn which
150        factor this account has — it is the same answer for both — and
151        pass that one. An account with neither passes with neither.
152
153        The window lands on the token in the ``Authorization`` header
154        and no other: proving on a laptop does not open it for a bearer
155        somebody else is holding.
156        """
157        body: dict[str, str] = {}
158        if current_password:
159            body["current_password"] = current_password
160        if code:
161            body["code"] = code
162        resp = self._http.request("POST", "/me/reauthenticate", json=body)
163        _raise_for_status(resp)
164        return ReauthenticationOpened.model_validate(resp.json())
165
166    def request_reauth_code(self) -> EmailVerificationSent:
167        """Mail this account's own address a code that opens the window.
168
169        Refused (409 ``reauth_proof_required``) unless a mailed code is
170        what this account re-proves with — one holding a password
171        re-enters that instead. ⚠️ A *different* code from the deletion
172        one: digits handed over under "confirm it is you" must not also
173        erase the account.
174        """
175        resp = self._http.request("POST", "/me/reauthenticate/code")
176        _raise_for_status(resp)
177        return EmailVerificationSent.model_validate(resp.json())
178
179    def request_deletion_code(self) -> EmailVerificationSent:
180        """Mail this account's own address a code that deletes it.
181
182        Refused (409 ``deletion_proof_required``) unless a mailed code is
183        what this account's proof actually is — read ``deletion_proof``
184        off :meth:`get` first. An account with a password re-enters that
185        instead, and one with neither needs no code at all.
186        """
187        resp = self._http.request("POST", "/me/deletion-code")
188        _raise_for_status(resp)
189        return EmailVerificationSent.model_validate(resp.json())
190
191    def delete_account(
192        self,
193        confirm_display_name: str,
194        *,
195        current_password: str = "",
196        code: str = "",
197    ) -> AccountDeletionAccepted:
198        """Self-delete the calling user.
199
200        ``confirm_display_name`` must match the caller's display name
201        (falls back to email when empty) exactly — the typo gate against
202        fat-fingered destruction, and **not** the authorization half: it
203        is a public string.
204
205        The second factor is whichever ``deletion_proof`` on
206        :meth:`get` names — ``current_password`` for an account holding
207        a password, ``code`` from :meth:`request_deletion_code` for one
208        proving a mailed address, neither when it answers ``none``. The
209        server composes that answer and enforces it, so read it rather
210        than guessing; sending the wrong one is a 401.
211
212        ⚠️ Both are keyword-only and default blank, so a caller written
213        before the gate existed still compiles — and still works against
214        an account whose answer is ``none``, which is every account that
215        predates it.
216
217        ⚠️ **Read the answer: this does not always delete anything yet.**
218        With ``account_deletion_grace_s`` above zero — the default — the
219        account is *scheduled*: its tokens are revoked and a job runs the
220        cascade later, and signing in before then cancels the whole
221        thing. ``deleted`` says which happened and ``effective_at`` says
222        when it becomes final. A caller that discards this cannot tell
223        *gone* from *recoverable*, which are opposite facts with opposite
224        next steps.
225        """
226        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
227        if current_password:
228            body["current_password"] = current_password
229        if code:
230            body["code"] = code
231        resp = self._http.request("DELETE", "/me", json=body)
232        _raise_for_status(resp)
233        return AccountDeletionAccepted.model_validate(resp.json())
234
235    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
236        """Caller's unlocked + locked badges (with progress hints)."""
237        params: dict[str, str] = {}
238        if competition_id:
239            params["competition_id"] = competition_id
240        resp = self._http.request("GET", "/me/achievements", params=params)
241        _raise_for_status(resp)
242        return MyAchievementsResponse.model_validate(resp.json())
243
244    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
245        """Per-challenge solve summary for the caller.
246
247        Unscoped this spans every team the caller has ever played for.
248        Pass ``competition_id`` to ask the narrower question a
249        competition-scoped surface means: have I solved this *here*.
250        """
251        params: dict[str, str] = {}
252        if competition_id:
253            params["competition_id"] = competition_id
254        resp = self._http.request("GET", "/me/solves", params=params)
255        _raise_for_status(resp)
256        return [MySolveSummary.model_validate(s) for s in resp.json()]
257
258    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
259        """Partial-solve progress per challenge for the caller.
260
261        One row per challenge with at least one captured question;
262        ``solved_question_ids`` enumerates which milestones the user
263        has cleared, ``total_questions`` is the spec's declared count.
264        With ``competition_id`` set the result narrows to solves
265        stamped against the caller's team in that comp.
266        """
267        params: dict[str, str] = {}
268        if competition_id:
269            params["competition_id"] = competition_id
270        resp = self._http.request("GET", "/me/milestone-progress", params=params)
271        _raise_for_status(resp)
272        return [MilestoneProgress.model_validate(s) for s in resp.json()]
273
274    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
275        """Verify the caller starred the ctfy GitHub repo and grant the
276        ``star_gazer`` badge if so.
277
278        Friendly failures (no GitHub identity, repo not configured, not
279        yet starred, GitHub rate-limited) come back as 200 with
280        ``verified=False`` + an actionable ``reason``."""
281        params = {"competition_id": competition_id} if competition_id else None
282        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
283        _raise_for_status(resp)
284        return StarGazerVerifyResponse.model_validate(resp.json())
class MeResource:
 24class MeResource:
 25    """Self-service surface for the authenticated caller."""
 26
 27    def __init__(self, http: BaseHttpClient) -> None:
 28        self._http = http
 29
 30    def get(self) -> MeResponse:
 31        """Get the calling user's profile + auth context.
 32
 33        Per-competition team memberships ride along on
 34        ``competition_teams``; there is no global "current team" any
 35        more — pick the row whose ``competition_id`` matches the
 36        scope you care about.
 37        """
 38        resp = self._http.request("GET", "/me")
 39        _raise_for_status(resp)
 40        return MeResponse.model_validate(resp.json())
 41
 42    def inbox(self) -> InboxResponse:
 43        """Pending team invites, join requests, direct invites, announcements."""
 44        resp = self._http.request("GET", "/me/inbox")
 45        _raise_for_status(resp)
 46        return InboxResponse.model_validate(resp.json())
 47
 48    def notification_preferences(self) -> NotificationPreferencesResponse:
 49        """Which categories reach the caller by email.
 50
 51        Each cell reports ``effective`` (what happens now), ``default``
 52        (what reverting would restore) and ``explicit`` (whether the user
 53        actually chose it, or is merely inheriting). Storage is sparse, so
 54        an untouched cell has no row — ``explicit`` is how you tell.
 55        """
 56        resp = self._http.request("GET", "/me/notification-preferences")
 57        _raise_for_status(resp)
 58        return NotificationPreferencesResponse.model_validate(resp.json())
 59
 60    def set_notification_preference(
 61        self, category: str, enabled: bool, *, channel: str = "email"
 62    ) -> NotificationPreferencesResponse:
 63        """Turn one category on or off for one channel.
 64
 65        ``security`` is refused with a 400 — sign-in codes and account
 66        alerts exist to protect the account and are not switchable.
 67        """
 68        resp = self._http.request(
 69            "PATCH",
 70            "/me/notification-preferences",
 71            json={"category": category, "channel": channel, "enabled": enabled},
 72        )
 73        _raise_for_status(resp)
 74        return NotificationPreferencesResponse.model_validate(resp.json())
 75
 76    def clear_notification_preference(
 77        self, category: str, *, channel: str = "email"
 78    ) -> NotificationPreferencesResponse:
 79        """Drop the explicit cell so the default applies again.
 80
 81        This is *revert*, not *disable*: for most categories the default
 82        is on, so clearing an explicit ``False`` turns mail back on.
 83        """
 84        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
 85        _raise_for_status(resp)
 86        return NotificationPreferencesResponse.model_validate(resp.json())
 87
 88    def read_announcement(self, announcement_id: str) -> None:
 89        """Mark one announcement as read for the calling user."""
 90        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
 91        _raise_for_status(resp)
 92
 93    def read_all_announcements(self) -> None:
 94        """Mark every relevant announcement as read."""
 95        resp = self._http.request("POST", "/me/announcements/read-all")
 96        _raise_for_status(resp)
 97
 98    def update_profile(self, **fields: Any) -> MeResponse:
 99        """PATCH the caller's profile.
100
101        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
102        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
103        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
104
105        Example::
106
107            client.me.update_profile(bio="hacking goblins", country="JP")
108            client.me.update_profile(website_url=None)  # clear it
109        """
110        resp = self._http.request("PATCH", "/me/profile", json=fields)
111        _raise_for_status(resp)
112        return MeResponse.model_validate(resp.json())
113
114    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
115        """PATCH per-field privacy flags on the caller's profile.
116
117        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
118        server-side. Returns the updated :class:`MeResponse`.
119        """
120        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
121        _raise_for_status(resp)
122        return MeResponse.model_validate(resp.json())
123
124    def export_account(self) -> bytes:
125        """Download a ZIP archive of every piece of data the platform
126        holds about the caller (user, identities, tokens, memberships,
127        submissions, solves, activity). Returns raw bytes — write to a
128        file with ``Path(...).write_bytes(client.me.export_account())``."""
129        resp = self._http.request("GET", "/me/export")
130        _raise_for_status(resp)
131        return resp.content
132
133    def reauthenticate(
134        self,
135        *,
136        current_password: str = "",
137        code: str = "",
138    ) -> ReauthenticationOpened:
139        """Prove a factor again, opening the sudo window on **this** token.
140
141        A handful of account changes — repointing the primary address,
142        setting or removing a password, unlinking an identity, minting a
143        token, widening one — refuse with ``409
144        reauthentication_required`` unless the credential making the
145        request proved a factor inside the last
146        ``reauth_window_s`` seconds. This is how it proves one.
147
148        Signing in already counts, so a freshly-minted session needs
149        nothing; this is for a bearer that has been held past the
150        window. Read ``deletion_proof`` off :meth:`get` to learn which
151        factor this account has — it is the same answer for both — and
152        pass that one. An account with neither passes with neither.
153
154        The window lands on the token in the ``Authorization`` header
155        and no other: proving on a laptop does not open it for a bearer
156        somebody else is holding.
157        """
158        body: dict[str, str] = {}
159        if current_password:
160            body["current_password"] = current_password
161        if code:
162            body["code"] = code
163        resp = self._http.request("POST", "/me/reauthenticate", json=body)
164        _raise_for_status(resp)
165        return ReauthenticationOpened.model_validate(resp.json())
166
167    def request_reauth_code(self) -> EmailVerificationSent:
168        """Mail this account's own address a code that opens the window.
169
170        Refused (409 ``reauth_proof_required``) unless a mailed code is
171        what this account re-proves with — one holding a password
172        re-enters that instead. ⚠️ A *different* code from the deletion
173        one: digits handed over under "confirm it is you" must not also
174        erase the account.
175        """
176        resp = self._http.request("POST", "/me/reauthenticate/code")
177        _raise_for_status(resp)
178        return EmailVerificationSent.model_validate(resp.json())
179
180    def request_deletion_code(self) -> EmailVerificationSent:
181        """Mail this account's own address a code that deletes it.
182
183        Refused (409 ``deletion_proof_required``) unless a mailed code is
184        what this account's proof actually is — read ``deletion_proof``
185        off :meth:`get` first. An account with a password re-enters that
186        instead, and one with neither needs no code at all.
187        """
188        resp = self._http.request("POST", "/me/deletion-code")
189        _raise_for_status(resp)
190        return EmailVerificationSent.model_validate(resp.json())
191
192    def delete_account(
193        self,
194        confirm_display_name: str,
195        *,
196        current_password: str = "",
197        code: str = "",
198    ) -> AccountDeletionAccepted:
199        """Self-delete the calling user.
200
201        ``confirm_display_name`` must match the caller's display name
202        (falls back to email when empty) exactly — the typo gate against
203        fat-fingered destruction, and **not** the authorization half: it
204        is a public string.
205
206        The second factor is whichever ``deletion_proof`` on
207        :meth:`get` names — ``current_password`` for an account holding
208        a password, ``code`` from :meth:`request_deletion_code` for one
209        proving a mailed address, neither when it answers ``none``. The
210        server composes that answer and enforces it, so read it rather
211        than guessing; sending the wrong one is a 401.
212
213        ⚠️ Both are keyword-only and default blank, so a caller written
214        before the gate existed still compiles — and still works against
215        an account whose answer is ``none``, which is every account that
216        predates it.
217
218        ⚠️ **Read the answer: this does not always delete anything yet.**
219        With ``account_deletion_grace_s`` above zero — the default — the
220        account is *scheduled*: its tokens are revoked and a job runs the
221        cascade later, and signing in before then cancels the whole
222        thing. ``deleted`` says which happened and ``effective_at`` says
223        when it becomes final. A caller that discards this cannot tell
224        *gone* from *recoverable*, which are opposite facts with opposite
225        next steps.
226        """
227        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
228        if current_password:
229            body["current_password"] = current_password
230        if code:
231            body["code"] = code
232        resp = self._http.request("DELETE", "/me", json=body)
233        _raise_for_status(resp)
234        return AccountDeletionAccepted.model_validate(resp.json())
235
236    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
237        """Caller's unlocked + locked badges (with progress hints)."""
238        params: dict[str, str] = {}
239        if competition_id:
240            params["competition_id"] = competition_id
241        resp = self._http.request("GET", "/me/achievements", params=params)
242        _raise_for_status(resp)
243        return MyAchievementsResponse.model_validate(resp.json())
244
245    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
246        """Per-challenge solve summary for the caller.
247
248        Unscoped this spans every team the caller has ever played for.
249        Pass ``competition_id`` to ask the narrower question a
250        competition-scoped surface means: have I solved this *here*.
251        """
252        params: dict[str, str] = {}
253        if competition_id:
254            params["competition_id"] = competition_id
255        resp = self._http.request("GET", "/me/solves", params=params)
256        _raise_for_status(resp)
257        return [MySolveSummary.model_validate(s) for s in resp.json()]
258
259    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
260        """Partial-solve progress per challenge for the caller.
261
262        One row per challenge with at least one captured question;
263        ``solved_question_ids`` enumerates which milestones the user
264        has cleared, ``total_questions`` is the spec's declared count.
265        With ``competition_id`` set the result narrows to solves
266        stamped against the caller's team in that comp.
267        """
268        params: dict[str, str] = {}
269        if competition_id:
270            params["competition_id"] = competition_id
271        resp = self._http.request("GET", "/me/milestone-progress", params=params)
272        _raise_for_status(resp)
273        return [MilestoneProgress.model_validate(s) for s in resp.json()]
274
275    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
276        """Verify the caller starred the ctfy GitHub repo and grant the
277        ``star_gazer`` badge if so.
278
279        Friendly failures (no GitHub identity, repo not configured, not
280        yet starred, GitHub rate-limited) come back as 200 with
281        ``verified=False`` + an actionable ``reason``."""
282        params = {"competition_id": competition_id} if competition_id else None
283        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
284        _raise_for_status(resp)
285        return StarGazerVerifyResponse.model_validate(resp.json())

Self-service surface for the authenticated caller.

MeResource(http: ctfy.sdk.base.BaseHttpClient)
27    def __init__(self, http: BaseHttpClient) -> None:
28        self._http = http
def get(self) -> ctfy.server.models.MeResponse:
30    def get(self) -> MeResponse:
31        """Get the calling user's profile + auth context.
32
33        Per-competition team memberships ride along on
34        ``competition_teams``; there is no global "current team" any
35        more — pick the row whose ``competition_id`` matches the
36        scope you care about.
37        """
38        resp = self._http.request("GET", "/me")
39        _raise_for_status(resp)
40        return MeResponse.model_validate(resp.json())

Get the calling user's profile + auth context.

Per-competition team memberships ride along on competition_teams; there is no global "current team" any more — pick the row whose competition_id matches the scope you care about.

def inbox(self) -> ctfy.server.models.InboxResponse:
42    def inbox(self) -> InboxResponse:
43        """Pending team invites, join requests, direct invites, announcements."""
44        resp = self._http.request("GET", "/me/inbox")
45        _raise_for_status(resp)
46        return InboxResponse.model_validate(resp.json())

Pending team invites, join requests, direct invites, announcements.

def notification_preferences(self) -> ctfy.server.models.NotificationPreferencesResponse:
48    def notification_preferences(self) -> NotificationPreferencesResponse:
49        """Which categories reach the caller by email.
50
51        Each cell reports ``effective`` (what happens now), ``default``
52        (what reverting would restore) and ``explicit`` (whether the user
53        actually chose it, or is merely inheriting). Storage is sparse, so
54        an untouched cell has no row — ``explicit`` is how you tell.
55        """
56        resp = self._http.request("GET", "/me/notification-preferences")
57        _raise_for_status(resp)
58        return NotificationPreferencesResponse.model_validate(resp.json())

Which categories reach the caller by email.

Each cell reports effective (what happens now), default (what reverting would restore) and explicit (whether the user actually chose it, or is merely inheriting). Storage is sparse, so an untouched cell has no row — explicit is how you tell.

def set_notification_preference( self, category: str, enabled: bool, *, channel: str = 'email') -> ctfy.server.models.NotificationPreferencesResponse:
60    def set_notification_preference(
61        self, category: str, enabled: bool, *, channel: str = "email"
62    ) -> NotificationPreferencesResponse:
63        """Turn one category on or off for one channel.
64
65        ``security`` is refused with a 400 — sign-in codes and account
66        alerts exist to protect the account and are not switchable.
67        """
68        resp = self._http.request(
69            "PATCH",
70            "/me/notification-preferences",
71            json={"category": category, "channel": channel, "enabled": enabled},
72        )
73        _raise_for_status(resp)
74        return NotificationPreferencesResponse.model_validate(resp.json())

Turn one category on or off for one channel.

security is refused with a 400 — sign-in codes and account alerts exist to protect the account and are not switchable.

def clear_notification_preference( self, category: str, *, channel: str = 'email') -> ctfy.server.models.NotificationPreferencesResponse:
76    def clear_notification_preference(
77        self, category: str, *, channel: str = "email"
78    ) -> NotificationPreferencesResponse:
79        """Drop the explicit cell so the default applies again.
80
81        This is *revert*, not *disable*: for most categories the default
82        is on, so clearing an explicit ``False`` turns mail back on.
83        """
84        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
85        _raise_for_status(resp)
86        return NotificationPreferencesResponse.model_validate(resp.json())

Drop the explicit cell so the default applies again.

This is revert, not disable: for most categories the default is on, so clearing an explicit False turns mail back on.

def read_announcement(self, announcement_id: str) -> None:
88    def read_announcement(self, announcement_id: str) -> None:
89        """Mark one announcement as read for the calling user."""
90        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
91        _raise_for_status(resp)

Mark one announcement as read for the calling user.

def read_all_announcements(self) -> None:
93    def read_all_announcements(self) -> None:
94        """Mark every relevant announcement as read."""
95        resp = self._http.request("POST", "/me/announcements/read-all")
96        _raise_for_status(resp)

Mark every relevant announcement as read.

def update_profile(self, **fields: Any) -> ctfy.server.models.MeResponse:
 98    def update_profile(self, **fields: Any) -> MeResponse:
 99        """PATCH the caller's profile.
100
101        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
102        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
103        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
104
105        Example::
106
107            client.me.update_profile(bio="hacking goblins", country="JP")
108            client.me.update_profile(website_url=None)  # clear it
109        """
110        resp = self._http.request("PATCH", "/me/profile", json=fields)
111        _raise_for_status(resp)
112        return MeResponse.model_validate(resp.json())

PATCH the caller's profile.

Pass any subset of bio / country / website_url / timezone / social_links. Omit a key to leave it unchanged; pass None to clear it. Returns the updated MeResponse.

Example::

client.me.update_profile(bio="hacking goblins", country="JP")
client.me.update_profile(website_url=None)  # clear it
def update_visibility(self, overrides: dict[str, bool]) -> ctfy.server.models.MeResponse:
114    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
115        """PATCH per-field privacy flags on the caller's profile.
116
117        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
118        server-side. Returns the updated :class:`MeResponse`.
119        """
120        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
121        _raise_for_status(resp)
122        return MeResponse.model_validate(resp.json())

PATCH per-field privacy flags on the caller's profile.

Keys outside PROFILE_VISIBILITY_KEYS are silently dropped server-side. Returns the updated MeResponse.

def export_account(self) -> bytes:
124    def export_account(self) -> bytes:
125        """Download a ZIP archive of every piece of data the platform
126        holds about the caller (user, identities, tokens, memberships,
127        submissions, solves, activity). Returns raw bytes — write to a
128        file with ``Path(...).write_bytes(client.me.export_account())``."""
129        resp = self._http.request("GET", "/me/export")
130        _raise_for_status(resp)
131        return resp.content

Download a ZIP archive of every piece of data the platform holds about the caller (user, identities, tokens, memberships, submissions, solves, activity). Returns raw bytes — write to a file with Path(...).write_bytes(client.me.export_account()).

def reauthenticate( self, *, current_password: str = '', code: str = '') -> ctfy.server.models.ReauthenticationOpened:
133    def reauthenticate(
134        self,
135        *,
136        current_password: str = "",
137        code: str = "",
138    ) -> ReauthenticationOpened:
139        """Prove a factor again, opening the sudo window on **this** token.
140
141        A handful of account changes — repointing the primary address,
142        setting or removing a password, unlinking an identity, minting a
143        token, widening one — refuse with ``409
144        reauthentication_required`` unless the credential making the
145        request proved a factor inside the last
146        ``reauth_window_s`` seconds. This is how it proves one.
147
148        Signing in already counts, so a freshly-minted session needs
149        nothing; this is for a bearer that has been held past the
150        window. Read ``deletion_proof`` off :meth:`get` to learn which
151        factor this account has — it is the same answer for both — and
152        pass that one. An account with neither passes with neither.
153
154        The window lands on the token in the ``Authorization`` header
155        and no other: proving on a laptop does not open it for a bearer
156        somebody else is holding.
157        """
158        body: dict[str, str] = {}
159        if current_password:
160            body["current_password"] = current_password
161        if code:
162            body["code"] = code
163        resp = self._http.request("POST", "/me/reauthenticate", json=body)
164        _raise_for_status(resp)
165        return ReauthenticationOpened.model_validate(resp.json())

Prove a factor again, opening the sudo window on this token.

A handful of account changes — repointing the primary address, setting or removing a password, unlinking an identity, minting a token, widening one — refuse with 409 reauthentication_required unless the credential making the request proved a factor inside the last reauth_window_s seconds. This is how it proves one.

Signing in already counts, so a freshly-minted session needs nothing; this is for a bearer that has been held past the window. Read deletion_proof off get() to learn which factor this account has — it is the same answer for both — and pass that one. An account with neither passes with neither.

The window lands on the token in the Authorization header and no other: proving on a laptop does not open it for a bearer somebody else is holding.

def request_reauth_code(self) -> ctfy.server.models.EmailVerificationSent:
167    def request_reauth_code(self) -> EmailVerificationSent:
168        """Mail this account's own address a code that opens the window.
169
170        Refused (409 ``reauth_proof_required``) unless a mailed code is
171        what this account re-proves with — one holding a password
172        re-enters that instead. ⚠️ A *different* code from the deletion
173        one: digits handed over under "confirm it is you" must not also
174        erase the account.
175        """
176        resp = self._http.request("POST", "/me/reauthenticate/code")
177        _raise_for_status(resp)
178        return EmailVerificationSent.model_validate(resp.json())

Mail this account's own address a code that opens the window.

Refused (409 reauth_proof_required) unless a mailed code is what this account re-proves with — one holding a password re-enters that instead. ⚠️ A different code from the deletion one: digits handed over under "confirm it is you" must not also erase the account.

def request_deletion_code(self) -> ctfy.server.models.EmailVerificationSent:
180    def request_deletion_code(self) -> EmailVerificationSent:
181        """Mail this account's own address a code that deletes it.
182
183        Refused (409 ``deletion_proof_required``) unless a mailed code is
184        what this account's proof actually is — read ``deletion_proof``
185        off :meth:`get` first. An account with a password re-enters that
186        instead, and one with neither needs no code at all.
187        """
188        resp = self._http.request("POST", "/me/deletion-code")
189        _raise_for_status(resp)
190        return EmailVerificationSent.model_validate(resp.json())

Mail this account's own address a code that deletes it.

Refused (409 deletion_proof_required) unless a mailed code is what this account's proof actually is — read deletion_proof off get() first. An account with a password re-enters that instead, and one with neither needs no code at all.

def delete_account( self, confirm_display_name: str, *, current_password: str = '', code: str = '') -> ctfy.server.models.AccountDeletionAccepted:
192    def delete_account(
193        self,
194        confirm_display_name: str,
195        *,
196        current_password: str = "",
197        code: str = "",
198    ) -> AccountDeletionAccepted:
199        """Self-delete the calling user.
200
201        ``confirm_display_name`` must match the caller's display name
202        (falls back to email when empty) exactly — the typo gate against
203        fat-fingered destruction, and **not** the authorization half: it
204        is a public string.
205
206        The second factor is whichever ``deletion_proof`` on
207        :meth:`get` names — ``current_password`` for an account holding
208        a password, ``code`` from :meth:`request_deletion_code` for one
209        proving a mailed address, neither when it answers ``none``. The
210        server composes that answer and enforces it, so read it rather
211        than guessing; sending the wrong one is a 401.
212
213        ⚠️ Both are keyword-only and default blank, so a caller written
214        before the gate existed still compiles — and still works against
215        an account whose answer is ``none``, which is every account that
216        predates it.
217
218        ⚠️ **Read the answer: this does not always delete anything yet.**
219        With ``account_deletion_grace_s`` above zero — the default — the
220        account is *scheduled*: its tokens are revoked and a job runs the
221        cascade later, and signing in before then cancels the whole
222        thing. ``deleted`` says which happened and ``effective_at`` says
223        when it becomes final. A caller that discards this cannot tell
224        *gone* from *recoverable*, which are opposite facts with opposite
225        next steps.
226        """
227        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
228        if current_password:
229            body["current_password"] = current_password
230        if code:
231            body["code"] = code
232        resp = self._http.request("DELETE", "/me", json=body)
233        _raise_for_status(resp)
234        return AccountDeletionAccepted.model_validate(resp.json())

Self-delete the calling user.

confirm_display_name must match the caller's display name (falls back to email when empty) exactly — the typo gate against fat-fingered destruction, and not the authorization half: it is a public string.

The second factor is whichever deletion_proof on get() names — current_password for an account holding a password, code from request_deletion_code() for one proving a mailed address, neither when it answers none. The server composes that answer and enforces it, so read it rather than guessing; sending the wrong one is a 401.

⚠️ Both are keyword-only and default blank, so a caller written before the gate existed still compiles — and still works against an account whose answer is none, which is every account that predates it.

⚠️ Read the answer: this does not always delete anything yet. With account_deletion_grace_s above zero — the default — the account is scheduled: its tokens are revoked and a job runs the cascade later, and signing in before then cancels the whole thing. deleted says which happened and effective_at says when it becomes final. A caller that discards this cannot tell gone from recoverable, which are opposite facts with opposite next steps.

def achievements( self, competition_id: str = '') -> ctfy.server.models.MyAchievementsResponse:
236    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
237        """Caller's unlocked + locked badges (with progress hints)."""
238        params: dict[str, str] = {}
239        if competition_id:
240            params["competition_id"] = competition_id
241        resp = self._http.request("GET", "/me/achievements", params=params)
242        _raise_for_status(resp)
243        return MyAchievementsResponse.model_validate(resp.json())

Caller's unlocked + locked badges (with progress hints).

def solves( self, *, competition_id: str = '') -> list[ctfy.server.models.MySolveSummary]:
245    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
246        """Per-challenge solve summary for the caller.
247
248        Unscoped this spans every team the caller has ever played for.
249        Pass ``competition_id`` to ask the narrower question a
250        competition-scoped surface means: have I solved this *here*.
251        """
252        params: dict[str, str] = {}
253        if competition_id:
254            params["competition_id"] = competition_id
255        resp = self._http.request("GET", "/me/solves", params=params)
256        _raise_for_status(resp)
257        return [MySolveSummary.model_validate(s) for s in resp.json()]

Per-challenge solve summary for the caller.

Unscoped this spans every team the caller has ever played for. Pass competition_id to ask the narrower question a competition-scoped surface means: have I solved this here.

def milestone_progress( self, *, competition_id: str = '') -> list[ctfy.server.models.MilestoneProgress]:
259    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
260        """Partial-solve progress per challenge for the caller.
261
262        One row per challenge with at least one captured question;
263        ``solved_question_ids`` enumerates which milestones the user
264        has cleared, ``total_questions`` is the spec's declared count.
265        With ``competition_id`` set the result narrows to solves
266        stamped against the caller's team in that comp.
267        """
268        params: dict[str, str] = {}
269        if competition_id:
270            params["competition_id"] = competition_id
271        resp = self._http.request("GET", "/me/milestone-progress", params=params)
272        _raise_for_status(resp)
273        return [MilestoneProgress.model_validate(s) for s in resp.json()]

Partial-solve progress per challenge for the caller.

One row per challenge with at least one captured question; solved_question_ids enumerates which milestones the user has cleared, total_questions is the spec's declared count. With competition_id set the result narrows to solves stamped against the caller's team in that comp.

def verify_star_gazer( self, competition_id: str = '') -> ctfy.server.models.StarGazerVerifyResponse:
275    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
276        """Verify the caller starred the ctfy GitHub repo and grant the
277        ``star_gazer`` badge if so.
278
279        Friendly failures (no GitHub identity, repo not configured, not
280        yet starred, GitHub rate-limited) come back as 200 with
281        ``verified=False`` + an actionable ``reason``."""
282        params = {"competition_id": competition_id} if competition_id else None
283        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
284        _raise_for_status(resp)
285        return StarGazerVerifyResponse.model_validate(resp.json())

Verify the caller starred the ctfy GitHub repo and grant the star_gazer badge if so.

Friendly failures (no GitHub identity, repo not configured, not yet starred, GitHub rate-limited) come back as 200 with verified=False + an actionable reason.