ctfy.sdk.admin_resources.challenges

client.admin.challenges — catalog rescan, pre-build, stats, feedback audit.

  1"""``client.admin.challenges`` — catalog rescan, pre-build, stats, feedback audit."""
  2
  3from __future__ import annotations
  4
  5from typing import Any
  6
  7from ctfy.sdk._helpers import _extract_items, _raise_for_status
  8from ctfy.sdk.base import BaseHttpClient
  9from ctfy.server.models import (
 10    AdminChallengeLatencyBucket,
 11    AdminChallengeStatsRow,
 12    AdminChallengeTimeseries,
 13    AdminFeedbackRow,
 14    AdminTaskInfo,
 15    ChallengeBuildStateResponse,
 16    ChallengePullStateResponse,
 17    QuestionAttemptResetInfo,
 18)
 19
 20
 21class AdminChallengesResource:
 22    """Operator challenge tooling: reload the catalog, pre-build on nodes,
 23    inspect attempt/solve stats, audit feedback, reset attempt caps."""
 24
 25    def __init__(self, http: BaseHttpClient) -> None:
 26        self._http = http
 27
 28    def rescan(self) -> AdminTaskInfo:
 29        """Submit a catalog-rescan background task (reload the platform's
 30        spec cache + fan the rescan out to every online node). Returns the
 31        task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page)
 32        for the per-node outcome in ``result``. Wraps
 33        ``POST /admin/challenges/rescan``."""
 34        resp = self._http.request("POST", "/admin/challenges/rescan")
 35        _raise_for_status(resp)
 36        return AdminTaskInfo.model_validate(resp.json())
 37
 38    def build(self, challenge_id: str) -> AdminTaskInfo:
 39        """Submit a single-challenge pre-build background task.
 40
 41        Fans the build out to every online node and polls it to
 42        completion; poll :meth:`build_state` (or the Tasks surface) to
 43        watch ``building`` → ``built`` / ``failed``. Returns the task
 44        record. Wraps ``POST /admin/challenges/{id}/build``."""
 45        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build")
 46        _raise_for_status(resp)
 47        return AdminTaskInfo.model_validate(resp.json())
 48
 49    def build_all(self, *, competition_id: str = "") -> AdminTaskInfo:
 50        """Submit a bulk pre-build background task. With ``competition_id``
 51        the build is scoped to that competition's effective challenge set;
 52        otherwise the whole catalog. The task kicks each online node off
 53        and polls them to completion; the per-node outcome + final
 54        per-challenge status land in ``result``. Returns the task record.
 55        Wraps ``POST /admin/challenges/build-all``."""
 56        params: dict[str, Any] = {}
 57        if competition_id:
 58            params["competition_id"] = competition_id
 59        resp = self._http.request("POST", "/admin/challenges/build-all", params=params)
 60        _raise_for_status(resp)
 61        return AdminTaskInfo.model_validate(resp.json())
 62
 63    def build_state(self) -> ChallengeBuildStateResponse:
 64        """Aggregate per-challenge build state across every online node.
 65
 66        Returns one row per known challenge with an ``aggregated``
 67        worst-case status plus the per-node breakdown for the modal
 68        drill-down. Wraps ``GET /admin/challenges/build-state``."""
 69        resp = self._http.request("GET", "/admin/challenges/build-state")
 70        _raise_for_status(resp)
 71        return ChallengeBuildStateResponse.model_validate(resp.json())
 72
 73    def pull(self, challenge_id: str) -> AdminTaskInfo:
 74        """Submit a single-challenge pre-pull background task (pull-side
 75        twin of :meth:`build`: warms registry-only ``image:`` services).
 76        Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``."""
 77        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull")
 78        _raise_for_status(resp)
 79        return AdminTaskInfo.model_validate(resp.json())
 80
 81    def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo:
 82        """Submit a bulk pre-pull background task (pull-side twin of
 83        :meth:`build_all`). Returns the task record. Wraps
 84        ``POST /admin/challenges/pull-all``."""
 85        params: dict[str, Any] = {}
 86        if competition_id:
 87            params["competition_id"] = competition_id
 88        resp = self._http.request("POST", "/admin/challenges/pull-all", params=params)
 89        _raise_for_status(resp)
 90        return AdminTaskInfo.model_validate(resp.json())
 91
 92    def pull_state(self) -> ChallengePullStateResponse:
 93        """Aggregate per-challenge pull state across every online node.
 94
 95        Pull-side twin of :meth:`build_state`. Wraps
 96        ``GET /admin/challenges/pull-state``."""
 97        resp = self._http.request("GET", "/admin/challenges/pull-state")
 98        _raise_for_status(resp)
 99        return ChallengePullStateResponse.model_validate(resp.json())
100
101    def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]:
102        """Per-challenge attempt / solve / first-blood counts."""
103        resp = self._http.request(
104            "GET",
105            "/admin/challenges/stats",
106            params={"offset": offset, "limit": limit},
107        )
108        _raise_for_status(resp)
109        return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]
110
111    def timeseries(
112        self,
113        challenge_id: str,
114        *,
115        window: int = 86400,
116        bucket: int = 3600,
117    ) -> AdminChallengeTimeseries:
118        """Per-challenge timeseries for the row-level drawer on
119        ``/admin/challenges``."""
120        resp = self._http.request(
121            "GET",
122            f"/admin/challenges/{challenge_id}/timeseries",
123            params={"window": window, "bucket": bucket},
124        )
125        _raise_for_status(resp)
126        return AdminChallengeTimeseries.model_validate(resp.json())
127
128    def latency_histogram(self) -> list[AdminChallengeLatencyBucket]:
129        """Instance-start latency distribution across challenges (the
130        admin challenges latency chart)."""
131        resp = self._http.request("GET", "/admin/challenges/latency-histogram")
132        _raise_for_status(resp)
133        return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]
134
135    def feedback(self, challenge_id: str, *, competition_id: str = "") -> list[AdminFeedbackRow]:
136        """Admin audit list: every reaction row on a challenge with
137        denormalised user identity. Requires admin role."""
138        params: dict[str, Any] = {}
139        if competition_id:
140            params["competition_id"] = competition_id
141        resp = self._http.request(
142            "GET", f"/admin/challenges/{challenge_id}/feedback", params=params
143        )
144        _raise_for_status(resp)
145        return _extract_items(resp.json(), AdminFeedbackRow)
146
147    def reset_question_attempts(
148        self,
149        team_id: str,
150        challenge_id: str,
151        question_id: str,
152        *,
153        reason: str = "",
154    ) -> QuestionAttemptResetInfo:
155        """Reset the per-question wrong-attempt counter for one team.
156
157        Writes a fresh baseline timestamp; the cap counter ignores all
158        wrong submissions before the new baseline, so the team gets a
159        fresh batch of attempts on this question without any audit
160        rows being deleted. Returns the new baseline row.
161        Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``."""
162        resp = self._http.request(
163            "POST",
164            f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts",
165            json={"reason": reason},
166        )
167        _raise_for_status(resp)
168        return QuestionAttemptResetInfo.model_validate(resp.json())
class AdminChallengesResource:
 22class AdminChallengesResource:
 23    """Operator challenge tooling: reload the catalog, pre-build on nodes,
 24    inspect attempt/solve stats, audit feedback, reset attempt caps."""
 25
 26    def __init__(self, http: BaseHttpClient) -> None:
 27        self._http = http
 28
 29    def rescan(self) -> AdminTaskInfo:
 30        """Submit a catalog-rescan background task (reload the platform's
 31        spec cache + fan the rescan out to every online node). Returns the
 32        task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page)
 33        for the per-node outcome in ``result``. Wraps
 34        ``POST /admin/challenges/rescan``."""
 35        resp = self._http.request("POST", "/admin/challenges/rescan")
 36        _raise_for_status(resp)
 37        return AdminTaskInfo.model_validate(resp.json())
 38
 39    def build(self, challenge_id: str) -> AdminTaskInfo:
 40        """Submit a single-challenge pre-build background task.
 41
 42        Fans the build out to every online node and polls it to
 43        completion; poll :meth:`build_state` (or the Tasks surface) to
 44        watch ``building`` → ``built`` / ``failed``. Returns the task
 45        record. Wraps ``POST /admin/challenges/{id}/build``."""
 46        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build")
 47        _raise_for_status(resp)
 48        return AdminTaskInfo.model_validate(resp.json())
 49
 50    def build_all(self, *, competition_id: str = "") -> AdminTaskInfo:
 51        """Submit a bulk pre-build background task. With ``competition_id``
 52        the build is scoped to that competition's effective challenge set;
 53        otherwise the whole catalog. The task kicks each online node off
 54        and polls them to completion; the per-node outcome + final
 55        per-challenge status land in ``result``. Returns the task record.
 56        Wraps ``POST /admin/challenges/build-all``."""
 57        params: dict[str, Any] = {}
 58        if competition_id:
 59            params["competition_id"] = competition_id
 60        resp = self._http.request("POST", "/admin/challenges/build-all", params=params)
 61        _raise_for_status(resp)
 62        return AdminTaskInfo.model_validate(resp.json())
 63
 64    def build_state(self) -> ChallengeBuildStateResponse:
 65        """Aggregate per-challenge build state across every online node.
 66
 67        Returns one row per known challenge with an ``aggregated``
 68        worst-case status plus the per-node breakdown for the modal
 69        drill-down. Wraps ``GET /admin/challenges/build-state``."""
 70        resp = self._http.request("GET", "/admin/challenges/build-state")
 71        _raise_for_status(resp)
 72        return ChallengeBuildStateResponse.model_validate(resp.json())
 73
 74    def pull(self, challenge_id: str) -> AdminTaskInfo:
 75        """Submit a single-challenge pre-pull background task (pull-side
 76        twin of :meth:`build`: warms registry-only ``image:`` services).
 77        Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``."""
 78        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull")
 79        _raise_for_status(resp)
 80        return AdminTaskInfo.model_validate(resp.json())
 81
 82    def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo:
 83        """Submit a bulk pre-pull background task (pull-side twin of
 84        :meth:`build_all`). Returns the task record. Wraps
 85        ``POST /admin/challenges/pull-all``."""
 86        params: dict[str, Any] = {}
 87        if competition_id:
 88            params["competition_id"] = competition_id
 89        resp = self._http.request("POST", "/admin/challenges/pull-all", params=params)
 90        _raise_for_status(resp)
 91        return AdminTaskInfo.model_validate(resp.json())
 92
 93    def pull_state(self) -> ChallengePullStateResponse:
 94        """Aggregate per-challenge pull state across every online node.
 95
 96        Pull-side twin of :meth:`build_state`. Wraps
 97        ``GET /admin/challenges/pull-state``."""
 98        resp = self._http.request("GET", "/admin/challenges/pull-state")
 99        _raise_for_status(resp)
100        return ChallengePullStateResponse.model_validate(resp.json())
101
102    def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]:
103        """Per-challenge attempt / solve / first-blood counts."""
104        resp = self._http.request(
105            "GET",
106            "/admin/challenges/stats",
107            params={"offset": offset, "limit": limit},
108        )
109        _raise_for_status(resp)
110        return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]
111
112    def timeseries(
113        self,
114        challenge_id: str,
115        *,
116        window: int = 86400,
117        bucket: int = 3600,
118    ) -> AdminChallengeTimeseries:
119        """Per-challenge timeseries for the row-level drawer on
120        ``/admin/challenges``."""
121        resp = self._http.request(
122            "GET",
123            f"/admin/challenges/{challenge_id}/timeseries",
124            params={"window": window, "bucket": bucket},
125        )
126        _raise_for_status(resp)
127        return AdminChallengeTimeseries.model_validate(resp.json())
128
129    def latency_histogram(self) -> list[AdminChallengeLatencyBucket]:
130        """Instance-start latency distribution across challenges (the
131        admin challenges latency chart)."""
132        resp = self._http.request("GET", "/admin/challenges/latency-histogram")
133        _raise_for_status(resp)
134        return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]
135
136    def feedback(self, challenge_id: str, *, competition_id: str = "") -> list[AdminFeedbackRow]:
137        """Admin audit list: every reaction row on a challenge with
138        denormalised user identity. Requires admin role."""
139        params: dict[str, Any] = {}
140        if competition_id:
141            params["competition_id"] = competition_id
142        resp = self._http.request(
143            "GET", f"/admin/challenges/{challenge_id}/feedback", params=params
144        )
145        _raise_for_status(resp)
146        return _extract_items(resp.json(), AdminFeedbackRow)
147
148    def reset_question_attempts(
149        self,
150        team_id: str,
151        challenge_id: str,
152        question_id: str,
153        *,
154        reason: str = "",
155    ) -> QuestionAttemptResetInfo:
156        """Reset the per-question wrong-attempt counter for one team.
157
158        Writes a fresh baseline timestamp; the cap counter ignores all
159        wrong submissions before the new baseline, so the team gets a
160        fresh batch of attempts on this question without any audit
161        rows being deleted. Returns the new baseline row.
162        Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``."""
163        resp = self._http.request(
164            "POST",
165            f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts",
166            json={"reason": reason},
167        )
168        _raise_for_status(resp)
169        return QuestionAttemptResetInfo.model_validate(resp.json())

Operator challenge tooling: reload the catalog, pre-build on nodes, inspect attempt/solve stats, audit feedback, reset attempt caps.

AdminChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
26    def __init__(self, http: BaseHttpClient) -> None:
27        self._http = http
def rescan(self) -> ctfy.server.models.AdminTaskInfo:
29    def rescan(self) -> AdminTaskInfo:
30        """Submit a catalog-rescan background task (reload the platform's
31        spec cache + fan the rescan out to every online node). Returns the
32        task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page)
33        for the per-node outcome in ``result``. Wraps
34        ``POST /admin/challenges/rescan``."""
35        resp = self._http.request("POST", "/admin/challenges/rescan")
36        _raise_for_status(resp)
37        return AdminTaskInfo.model_validate(resp.json())

Submit a catalog-rescan background task (reload the platform's spec cache + fan the rescan out to every online node). Returns the task record; poll admin.tasks.get(id) (or watch the Tasks page) for the per-node outcome in result. Wraps POST /admin/challenges/rescan.

def build(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
39    def build(self, challenge_id: str) -> AdminTaskInfo:
40        """Submit a single-challenge pre-build background task.
41
42        Fans the build out to every online node and polls it to
43        completion; poll :meth:`build_state` (or the Tasks surface) to
44        watch ``building`` → ``built`` / ``failed``. Returns the task
45        record. Wraps ``POST /admin/challenges/{id}/build``."""
46        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build")
47        _raise_for_status(resp)
48        return AdminTaskInfo.model_validate(resp.json())

Submit a single-challenge pre-build background task.

Fans the build out to every online node and polls it to completion; poll build_state() (or the Tasks surface) to watch buildingbuilt / failed. Returns the task record. Wraps POST /admin/challenges/{id}/build.

def build_all( self, *, competition_id: str = '') -> ctfy.server.models.AdminTaskInfo:
50    def build_all(self, *, competition_id: str = "") -> AdminTaskInfo:
51        """Submit a bulk pre-build background task. With ``competition_id``
52        the build is scoped to that competition's effective challenge set;
53        otherwise the whole catalog. The task kicks each online node off
54        and polls them to completion; the per-node outcome + final
55        per-challenge status land in ``result``. Returns the task record.
56        Wraps ``POST /admin/challenges/build-all``."""
57        params: dict[str, Any] = {}
58        if competition_id:
59            params["competition_id"] = competition_id
60        resp = self._http.request("POST", "/admin/challenges/build-all", params=params)
61        _raise_for_status(resp)
62        return AdminTaskInfo.model_validate(resp.json())

Submit a bulk pre-build background task. With competition_id the build is scoped to that competition's effective challenge set; otherwise the whole catalog. The task kicks each online node off and polls them to completion; the per-node outcome + final per-challenge status land in result. Returns the task record. Wraps POST /admin/challenges/build-all.

def build_state(self) -> ctfy.server.models.ChallengeBuildStateResponse:
64    def build_state(self) -> ChallengeBuildStateResponse:
65        """Aggregate per-challenge build state across every online node.
66
67        Returns one row per known challenge with an ``aggregated``
68        worst-case status plus the per-node breakdown for the modal
69        drill-down. Wraps ``GET /admin/challenges/build-state``."""
70        resp = self._http.request("GET", "/admin/challenges/build-state")
71        _raise_for_status(resp)
72        return ChallengeBuildStateResponse.model_validate(resp.json())

Aggregate per-challenge build state across every online node.

Returns one row per known challenge with an aggregated worst-case status plus the per-node breakdown for the modal drill-down. Wraps GET /admin/challenges/build-state.

def pull(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
74    def pull(self, challenge_id: str) -> AdminTaskInfo:
75        """Submit a single-challenge pre-pull background task (pull-side
76        twin of :meth:`build`: warms registry-only ``image:`` services).
77        Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``."""
78        resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull")
79        _raise_for_status(resp)
80        return AdminTaskInfo.model_validate(resp.json())

Submit a single-challenge pre-pull background task (pull-side twin of build(): warms registry-only image: services). Returns the task record. Wraps POST /admin/challenges/{id}/pull.

def pull_all( self, *, competition_id: str = '') -> ctfy.server.models.AdminTaskInfo:
82    def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo:
83        """Submit a bulk pre-pull background task (pull-side twin of
84        :meth:`build_all`). Returns the task record. Wraps
85        ``POST /admin/challenges/pull-all``."""
86        params: dict[str, Any] = {}
87        if competition_id:
88            params["competition_id"] = competition_id
89        resp = self._http.request("POST", "/admin/challenges/pull-all", params=params)
90        _raise_for_status(resp)
91        return AdminTaskInfo.model_validate(resp.json())

Submit a bulk pre-pull background task (pull-side twin of build_all()). Returns the task record. Wraps POST /admin/challenges/pull-all.

def pull_state(self) -> ctfy.server.models.ChallengePullStateResponse:
 93    def pull_state(self) -> ChallengePullStateResponse:
 94        """Aggregate per-challenge pull state across every online node.
 95
 96        Pull-side twin of :meth:`build_state`. Wraps
 97        ``GET /admin/challenges/pull-state``."""
 98        resp = self._http.request("GET", "/admin/challenges/pull-state")
 99        _raise_for_status(resp)
100        return ChallengePullStateResponse.model_validate(resp.json())

Aggregate per-challenge pull state across every online node.

Pull-side twin of build_state(). Wraps GET /admin/challenges/pull-state.

def stats( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.AdminChallengeStatsRow]:
102    def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]:
103        """Per-challenge attempt / solve / first-blood counts."""
104        resp = self._http.request(
105            "GET",
106            "/admin/challenges/stats",
107            params={"offset": offset, "limit": limit},
108        )
109        _raise_for_status(resp)
110        return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]

Per-challenge attempt / solve / first-blood counts.

def timeseries( self, challenge_id: str, *, window: int = 86400, bucket: int = 3600) -> ctfy.server.models.AdminChallengeTimeseries:
112    def timeseries(
113        self,
114        challenge_id: str,
115        *,
116        window: int = 86400,
117        bucket: int = 3600,
118    ) -> AdminChallengeTimeseries:
119        """Per-challenge timeseries for the row-level drawer on
120        ``/admin/challenges``."""
121        resp = self._http.request(
122            "GET",
123            f"/admin/challenges/{challenge_id}/timeseries",
124            params={"window": window, "bucket": bucket},
125        )
126        _raise_for_status(resp)
127        return AdminChallengeTimeseries.model_validate(resp.json())

Per-challenge timeseries for the row-level drawer on /admin/challenges.

def latency_histogram(self) -> list[ctfy.server.models.AdminChallengeLatencyBucket]:
129    def latency_histogram(self) -> list[AdminChallengeLatencyBucket]:
130        """Instance-start latency distribution across challenges (the
131        admin challenges latency chart)."""
132        resp = self._http.request("GET", "/admin/challenges/latency-histogram")
133        _raise_for_status(resp)
134        return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]

Instance-start latency distribution across challenges (the admin challenges latency chart).

def feedback( self, challenge_id: str, *, competition_id: str = '') -> list[ctfy.server.models.AdminFeedbackRow]:
136    def feedback(self, challenge_id: str, *, competition_id: str = "") -> list[AdminFeedbackRow]:
137        """Admin audit list: every reaction row on a challenge with
138        denormalised user identity. Requires admin role."""
139        params: dict[str, Any] = {}
140        if competition_id:
141            params["competition_id"] = competition_id
142        resp = self._http.request(
143            "GET", f"/admin/challenges/{challenge_id}/feedback", params=params
144        )
145        _raise_for_status(resp)
146        return _extract_items(resp.json(), AdminFeedbackRow)

Admin audit list: every reaction row on a challenge with denormalised user identity. Requires admin role.

def reset_question_attempts( self, team_id: str, challenge_id: str, question_id: str, *, reason: str = '') -> ctfy.server.models.QuestionAttemptResetInfo:
148    def reset_question_attempts(
149        self,
150        team_id: str,
151        challenge_id: str,
152        question_id: str,
153        *,
154        reason: str = "",
155    ) -> QuestionAttemptResetInfo:
156        """Reset the per-question wrong-attempt counter for one team.
157
158        Writes a fresh baseline timestamp; the cap counter ignores all
159        wrong submissions before the new baseline, so the team gets a
160        fresh batch of attempts on this question without any audit
161        rows being deleted. Returns the new baseline row.
162        Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``."""
163        resp = self._http.request(
164            "POST",
165            f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts",
166            json={"reason": reason},
167        )
168        _raise_for_status(resp)
169        return QuestionAttemptResetInfo.model_validate(resp.json())

Reset the per-question wrong-attempt counter for one team.

Writes a fresh baseline timestamp; the cap counter ignores all wrong submissions before the new baseline, so the team gets a fresh batch of attempts on this question without any audit rows being deleted. Returns the new baseline row. Wraps POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts.