ctfy.sdk.admin_resources.release_plan
client.admin.release_plan — waves, per-challenge rules and hints of
a competition: drafts, publishing and releasing by hand (#2093 phase 2, #2101).
A draft changes nothing a player sees; AdminReleasePlanResource.
publish() and ~AdminReleasePlanResource.release_now() do, and a release
is never taken back.
⚠️ Every request names its method and path as literals at the call site —
test_sdk_openapi_parity reads them there, so a shared helper taking the
path as an argument would hide every route it serves.
1"""``client.admin.release_plan`` — waves, per-challenge rules and hints of 2a competition: drafts, publishing and releasing by hand (#2093 phase 2, #2101). 3 4A draft changes nothing a player sees; :meth:`AdminReleasePlanResource. 5publish` and :meth:`~AdminReleasePlanResource.release_now` do, and a release 6is never taken back. 7 8⚠️ Every request names its method and path as literals at the call site — 9``test_sdk_openapi_parity`` reads them there, so a shared helper taking the 10path as an argument would hide every route it serves. 11""" 12 13from __future__ import annotations 14 15from collections.abc import Sequence 16from datetime import datetime 17from typing import Any 18 19import httpx 20 21from ctfy.core.release_conditions import PacePreset, ReleaseRule 22from ctfy.core.state.models import HintTierDraft 23from ctfy.sdk._helpers import _raise_for_status 24from ctfy.sdk.base import BaseHttpClient 25from ctfy.server.models import HintAttachmentUpload, HintEditorView, ReleasePlanView 26 27 28def _plan(resp: httpx.Response) -> ReleasePlanView: 29 _raise_for_status(resp) 30 return ReleasePlanView.model_validate(resp.json()) 31 32 33def _editor(resp: httpx.Response) -> HintEditorView: 34 _raise_for_status(resp) 35 return HintEditorView.model_validate(resp.json()) 36 37 38class AdminReleasePlanResource: 39 """For a competition's organisers (global admins and its own admins).""" 40 41 def __init__(self, http: BaseHttpClient) -> None: 42 self._http = http 43 44 # -- waves and rules ------------------------------------------------------- 45 46 def get(self, competition_id: str, *, pace: PacePreset = "normal") -> ReleasePlanView: 47 """Every wave and challenge with its rule, draft, standing, forecast 48 (under the assumed ``pace``) and the findings.""" 49 return _plan( 50 self._http.request( 51 "GET", f"/admin/competitions/{competition_id}/release-plan", params={"pace": pace} 52 ) 53 ) 54 55 def save_draft(self, competition_id: str, subject: str, rule: ReleaseRule) -> ReleasePlanView: 56 """Save a draft for ``wave:<name>`` or ``challenge:<id>``.""" 57 return _plan( 58 self._http.request( 59 "PUT", 60 f"/admin/competitions/{competition_id}/release-plan/drafts", 61 json={"subject": subject, "rule": rule.model_dump(mode="json")}, 62 ) 63 ) 64 65 def discard_draft(self, competition_id: str, subject: str) -> ReleasePlanView: 66 return _plan( 67 self._http.request( 68 "DELETE", 69 f"/admin/competitions/{competition_id}/release-plan/drafts", 70 params={"subject": subject}, 71 ) 72 ) 73 74 def publish(self, competition_id: str, subject: str) -> ReleasePlanView: 75 """Publish ``subject``'s draft (refused with findings when invalid).""" 76 return _plan( 77 self._http.request( 78 "POST", 79 f"/admin/competitions/{competition_id}/release-plan/publish", 80 json={"subject": subject}, 81 ) 82 ) 83 84 def release_now(self, competition_id: str, subject: str) -> ReleasePlanView: 85 """Release a wave or a challenge now, whatever its rule says.""" 86 return _plan( 87 self._http.request( 88 "POST", 89 f"/admin/competitions/{competition_id}/release-plan/release", 90 json={"subject": subject}, 91 ) 92 ) 93 94 def follow_wave(self, competition_id: str, challenge_id: str) -> ReleasePlanView: 95 """Drop a challenge's own rule; it follows its wave again.""" 96 return _plan( 97 self._http.request( 98 "DELETE", 99 f"/admin/competitions/{competition_id}/release-plan/rules", 100 params={"subject": f"challenge:{challenge_id}"}, 101 ) 102 ) 103 104 def create_wave( 105 self, competition_id: str, name: str, challenge_ids: Sequence[str] = () 106 ) -> ReleasePlanView: 107 return _plan( 108 self._http.request( 109 "POST", 110 f"/admin/competitions/{competition_id}/release-plan/waves", 111 json={"name": name, "challenge_ids": list(challenge_ids)}, 112 ) 113 ) 114 115 def delete_wave(self, competition_id: str, name: str) -> ReleasePlanView: 116 return _plan( 117 self._http.request( 118 "DELETE", 119 f"/admin/competitions/{competition_id}/release-plan/waves", 120 params={"name": name}, 121 ) 122 ) 123 124 # -- hints ----------------------------------------------------------------- 125 126 def hints( 127 self, 128 competition_id: str, 129 challenge_id: str, 130 *, 131 solved_at: datetime | None = None, 132 solved_teams: int = 1, 133 ) -> HintEditorView: 134 """The tiers, the draft, the findings and the two scenarios.""" 135 params: dict[str, Any] = {"solved_teams": solved_teams} 136 if solved_at is not None: 137 params["solved_at"] = solved_at.isoformat() 138 return _editor( 139 self._http.request( 140 "GET", 141 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints", 142 params=params, 143 ) 144 ) 145 146 def save_hint_draft( 147 self, competition_id: str, challenge_id: str, tiers: Sequence[HintTierDraft] 148 ) -> HintEditorView: 149 return _editor( 150 self._http.request( 151 "PUT", 152 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/draft", 153 json={"tiers": [t.model_dump(mode="json") for t in tiers]}, 154 ) 155 ) 156 157 def discard_hint_draft(self, competition_id: str, challenge_id: str) -> HintEditorView: 158 return _editor( 159 self._http.request( 160 "DELETE", 161 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/draft", 162 ) 163 ) 164 165 def publish_hints(self, competition_id: str, challenge_id: str) -> HintEditorView: 166 return _editor( 167 self._http.request( 168 "POST", 169 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/publish", 170 ) 171 ) 172 173 def release_hint(self, competition_id: str, challenge_id: str, hint_id: str) -> HintEditorView: 174 return _editor( 175 self._http.request( 176 "POST", 177 f"/admin/competitions/{competition_id}/challenges/{challenge_id}" 178 f"/hints/{hint_id}/release", 179 ) 180 ) 181 182 def upload_hint_attachment( 183 self, competition_id: str, filename: str, content: bytes 184 ) -> HintAttachmentUpload: 185 """Store a file for a tier; put the answer's fields into a draft tier.""" 186 resp = self._http.request( 187 "POST", 188 f"/admin/competitions/{competition_id}/hint-attachments", 189 files={"file": (filename, content, "application/octet-stream")}, 190 ) 191 _raise_for_status(resp) 192 return HintAttachmentUpload.model_validate(resp.json())
39class AdminReleasePlanResource: 40 """For a competition's organisers (global admins and its own admins).""" 41 42 def __init__(self, http: BaseHttpClient) -> None: 43 self._http = http 44 45 # -- waves and rules ------------------------------------------------------- 46 47 def get(self, competition_id: str, *, pace: PacePreset = "normal") -> ReleasePlanView: 48 """Every wave and challenge with its rule, draft, standing, forecast 49 (under the assumed ``pace``) and the findings.""" 50 return _plan( 51 self._http.request( 52 "GET", f"/admin/competitions/{competition_id}/release-plan", params={"pace": pace} 53 ) 54 ) 55 56 def save_draft(self, competition_id: str, subject: str, rule: ReleaseRule) -> ReleasePlanView: 57 """Save a draft for ``wave:<name>`` or ``challenge:<id>``.""" 58 return _plan( 59 self._http.request( 60 "PUT", 61 f"/admin/competitions/{competition_id}/release-plan/drafts", 62 json={"subject": subject, "rule": rule.model_dump(mode="json")}, 63 ) 64 ) 65 66 def discard_draft(self, competition_id: str, subject: str) -> ReleasePlanView: 67 return _plan( 68 self._http.request( 69 "DELETE", 70 f"/admin/competitions/{competition_id}/release-plan/drafts", 71 params={"subject": subject}, 72 ) 73 ) 74 75 def publish(self, competition_id: str, subject: str) -> ReleasePlanView: 76 """Publish ``subject``'s draft (refused with findings when invalid).""" 77 return _plan( 78 self._http.request( 79 "POST", 80 f"/admin/competitions/{competition_id}/release-plan/publish", 81 json={"subject": subject}, 82 ) 83 ) 84 85 def release_now(self, competition_id: str, subject: str) -> ReleasePlanView: 86 """Release a wave or a challenge now, whatever its rule says.""" 87 return _plan( 88 self._http.request( 89 "POST", 90 f"/admin/competitions/{competition_id}/release-plan/release", 91 json={"subject": subject}, 92 ) 93 ) 94 95 def follow_wave(self, competition_id: str, challenge_id: str) -> ReleasePlanView: 96 """Drop a challenge's own rule; it follows its wave again.""" 97 return _plan( 98 self._http.request( 99 "DELETE", 100 f"/admin/competitions/{competition_id}/release-plan/rules", 101 params={"subject": f"challenge:{challenge_id}"}, 102 ) 103 ) 104 105 def create_wave( 106 self, competition_id: str, name: str, challenge_ids: Sequence[str] = () 107 ) -> ReleasePlanView: 108 return _plan( 109 self._http.request( 110 "POST", 111 f"/admin/competitions/{competition_id}/release-plan/waves", 112 json={"name": name, "challenge_ids": list(challenge_ids)}, 113 ) 114 ) 115 116 def delete_wave(self, competition_id: str, name: str) -> ReleasePlanView: 117 return _plan( 118 self._http.request( 119 "DELETE", 120 f"/admin/competitions/{competition_id}/release-plan/waves", 121 params={"name": name}, 122 ) 123 ) 124 125 # -- hints ----------------------------------------------------------------- 126 127 def hints( 128 self, 129 competition_id: str, 130 challenge_id: str, 131 *, 132 solved_at: datetime | None = None, 133 solved_teams: int = 1, 134 ) -> HintEditorView: 135 """The tiers, the draft, the findings and the two scenarios.""" 136 params: dict[str, Any] = {"solved_teams": solved_teams} 137 if solved_at is not None: 138 params["solved_at"] = solved_at.isoformat() 139 return _editor( 140 self._http.request( 141 "GET", 142 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints", 143 params=params, 144 ) 145 ) 146 147 def save_hint_draft( 148 self, competition_id: str, challenge_id: str, tiers: Sequence[HintTierDraft] 149 ) -> HintEditorView: 150 return _editor( 151 self._http.request( 152 "PUT", 153 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/draft", 154 json={"tiers": [t.model_dump(mode="json") for t in tiers]}, 155 ) 156 ) 157 158 def discard_hint_draft(self, competition_id: str, challenge_id: str) -> HintEditorView: 159 return _editor( 160 self._http.request( 161 "DELETE", 162 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/draft", 163 ) 164 ) 165 166 def publish_hints(self, competition_id: str, challenge_id: str) -> HintEditorView: 167 return _editor( 168 self._http.request( 169 "POST", 170 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/publish", 171 ) 172 ) 173 174 def release_hint(self, competition_id: str, challenge_id: str, hint_id: str) -> HintEditorView: 175 return _editor( 176 self._http.request( 177 "POST", 178 f"/admin/competitions/{competition_id}/challenges/{challenge_id}" 179 f"/hints/{hint_id}/release", 180 ) 181 ) 182 183 def upload_hint_attachment( 184 self, competition_id: str, filename: str, content: bytes 185 ) -> HintAttachmentUpload: 186 """Store a file for a tier; put the answer's fields into a draft tier.""" 187 resp = self._http.request( 188 "POST", 189 f"/admin/competitions/{competition_id}/hint-attachments", 190 files={"file": (filename, content, "application/octet-stream")}, 191 ) 192 _raise_for_status(resp) 193 return HintAttachmentUpload.model_validate(resp.json())
For a competition's organisers (global admins and its own admins).
47 def get(self, competition_id: str, *, pace: PacePreset = "normal") -> ReleasePlanView: 48 """Every wave and challenge with its rule, draft, standing, forecast 49 (under the assumed ``pace``) and the findings.""" 50 return _plan( 51 self._http.request( 52 "GET", f"/admin/competitions/{competition_id}/release-plan", params={"pace": pace} 53 ) 54 )
Every wave and challenge with its rule, draft, standing, forecast
(under the assumed pace) and the findings.
56 def save_draft(self, competition_id: str, subject: str, rule: ReleaseRule) -> ReleasePlanView: 57 """Save a draft for ``wave:<name>`` or ``challenge:<id>``.""" 58 return _plan( 59 self._http.request( 60 "PUT", 61 f"/admin/competitions/{competition_id}/release-plan/drafts", 62 json={"subject": subject, "rule": rule.model_dump(mode="json")}, 63 ) 64 )
Save a draft for wave:<name> or challenge:<id>.
75 def publish(self, competition_id: str, subject: str) -> ReleasePlanView: 76 """Publish ``subject``'s draft (refused with findings when invalid).""" 77 return _plan( 78 self._http.request( 79 "POST", 80 f"/admin/competitions/{competition_id}/release-plan/publish", 81 json={"subject": subject}, 82 ) 83 )
Publish subject's draft (refused with findings when invalid).
85 def release_now(self, competition_id: str, subject: str) -> ReleasePlanView: 86 """Release a wave or a challenge now, whatever its rule says.""" 87 return _plan( 88 self._http.request( 89 "POST", 90 f"/admin/competitions/{competition_id}/release-plan/release", 91 json={"subject": subject}, 92 ) 93 )
Release a wave or a challenge now, whatever its rule says.
95 def follow_wave(self, competition_id: str, challenge_id: str) -> ReleasePlanView: 96 """Drop a challenge's own rule; it follows its wave again.""" 97 return _plan( 98 self._http.request( 99 "DELETE", 100 f"/admin/competitions/{competition_id}/release-plan/rules", 101 params={"subject": f"challenge:{challenge_id}"}, 102 ) 103 )
Drop a challenge's own rule; it follows its wave again.
105 def create_wave( 106 self, competition_id: str, name: str, challenge_ids: Sequence[str] = () 107 ) -> ReleasePlanView: 108 return _plan( 109 self._http.request( 110 "POST", 111 f"/admin/competitions/{competition_id}/release-plan/waves", 112 json={"name": name, "challenge_ids": list(challenge_ids)}, 113 ) 114 )
127 def hints( 128 self, 129 competition_id: str, 130 challenge_id: str, 131 *, 132 solved_at: datetime | None = None, 133 solved_teams: int = 1, 134 ) -> HintEditorView: 135 """The tiers, the draft, the findings and the two scenarios.""" 136 params: dict[str, Any] = {"solved_teams": solved_teams} 137 if solved_at is not None: 138 params["solved_at"] = solved_at.isoformat() 139 return _editor( 140 self._http.request( 141 "GET", 142 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints", 143 params=params, 144 ) 145 )
The tiers, the draft, the findings and the two scenarios.
147 def save_hint_draft( 148 self, competition_id: str, challenge_id: str, tiers: Sequence[HintTierDraft] 149 ) -> HintEditorView: 150 return _editor( 151 self._http.request( 152 "PUT", 153 f"/admin/competitions/{competition_id}/challenges/{challenge_id}/hints/draft", 154 json={"tiers": [t.model_dump(mode="json") for t in tiers]}, 155 ) 156 )
183 def upload_hint_attachment( 184 self, competition_id: str, filename: str, content: bytes 185 ) -> HintAttachmentUpload: 186 """Store a file for a tier; put the answer's fields into a draft tier.""" 187 resp = self._http.request( 188 "POST", 189 f"/admin/competitions/{competition_id}/hint-attachments", 190 files={"file": (filename, content, "application/octet-stream")}, 191 ) 192 _raise_for_status(resp) 193 return HintAttachmentUpload.model_validate(resp.json())
Store a file for a tier; put the answer's fields into a draft tier.