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())
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.
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.
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 building → built / failed. Returns the task
record. Wraps POST /admin/challenges/{id}/build.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.