ctfy.sdk.resources.reports
client.reports — the engagement report a team is graded on.
1"""``client.reports`` — the engagement report a team is graded on.""" 2 3from __future__ import annotations 4 5from ctfy.sdk._helpers import _raise_for_status 6from ctfy.sdk.base import BaseHttpClient 7from ctfy.server.models import ( 8 MyReportGradesResponse, 9 MyReportsResponse, 10 ReportAttachmentInfo, 11 ReportAttachmentsResponse, 12 ReportInfo, 13 ReportSaveResponse, 14 ReportStanding, 15 ReportVersionsResponse, 16) 17 18 19class ReportsResource: 20 """One revisable report per (team, competition, challenge). 21 22 A GAUNTLET range is one estate with its own kill chain, so an event 23 curating four of them is four engagements and four write-ups. The 24 competition still owns the deadline; the artifact is per range. 25 26 ⚠️ **The last version saved is the one that gets graded**, and every 27 version is kept for the appeal. So :meth:`save` sends the whole 28 document each time — there is no append — and an empty one is 29 refused rather than stored, because an accidental blank save would 30 otherwise replace the artifact the team is scored on. 31 """ 32 33 def __init__(self, http: BaseHttpClient) -> None: 34 self._http = http 35 36 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 37 """Save a new version; get back what it wrote and what it credited. 38 39 Refused outside the competition's play sessions and for a 40 disqualified team, exactly like every other progress path — the 41 report *is* this format's progress path. 42 43 ``newly_credited`` counts the sentinels quoted in **this** save 44 that the team did not already hold, which is the whole feedback 45 channel the format allows: enough to learn the last edit was 46 worth something, never enough to learn how many exist. 47 """ 48 resp = self._http.request( 49 "PUT", 50 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 51 json={"content": content}, 52 ) 53 _raise_for_status(resp) 54 return ReportSaveResponse.model_validate(resp.json()) 55 56 def mine(self, competition_id: str) -> MyReportsResponse: 57 """Which ranges this team has written up, and how much of each. 58 59 Only ranges with a saved report — the ones not started are the 60 competition's challenge list minus these, which the caller 61 already holds. 62 """ 63 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 64 _raise_for_status(resp) 65 return MyReportsResponse.model_validate(resp.json()) 66 67 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 68 """This team's current report on one range — the highest version. 69 70 **404 when nothing has been saved**, rather than an empty 71 report: a blank body would be indistinguishable from a saved 72 empty one, and the write path refuses those precisely so the two 73 remain different facts. 74 """ 75 resp = self._http.request( 76 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 77 ) 78 _raise_for_status(resp) 79 return ReportInfo.model_validate(resp.json()) 80 81 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 82 """Every version saved for one range, newest first, with bodies.""" 83 resp = self._http.request( 84 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 85 ) 86 _raise_for_status(resp) 87 return ReportVersionsResponse.model_validate(resp.json()) 88 89 def grades(self, competition_id: str, challenge_id: str) -> MyReportGradesResponse: 90 """How each graded version of this team's report on one range did. 91 92 Per version: the points, the counts, and each finding the team holds 93 marked ``accurate`` / ``inaccurate`` / ``not_mentioned``. Only 94 findings the team already reached appear, and no answer value ever 95 does. 96 97 **403 ``report_grades_withheld``** while the competition does not 98 show its players their grades — a silent exercise, by default, until 99 the report locks. 100 """ 101 resp = self._http.request( 102 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grades" 103 ) 104 _raise_for_status(resp) 105 return MyReportGradesResponse.model_validate(resp.json()) 106 107 # -- 交卷, and asking to be judged now --------------------------------- 108 # 109 # ⚠️ **Two verbs, not one with a flag.** They are separately 110 # reachable in both directions: a team may want a verdict on a draft 111 # it fully intends to keep editing, and a team that has run out of 112 # grading requests must still be able to hand its work in. One call 113 # with ``finalize=True`` would make the action that must never be 114 # refused draw on the quota that can refuse it. 115 116 def finalize(self, competition_id: str, challenge_id: str) -> ReportStanding: 117 """交卷 — declare the current version this range's deliverable. 118 119 The save route then refuses a new version until :meth:`reopen`, 120 and, quota permitting, the report is judged now rather than when 121 the quiet window elapses. 122 123 ⚠️ **Never refused for want of quota.** Past the bound this still 124 hands the report in and the ordinary quiet window grades it, 125 which it must — a finalised report cannot be saved again. 126 """ 127 resp = self._http.request( 128 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/finalize" 129 ) 130 _raise_for_status(resp) 131 return ReportStanding.model_validate(resp.json()) 132 133 def reopen(self, competition_id: str, challenge_id: str) -> ReportStanding: 134 """Withdraw 交卷 so the team can save again. 135 136 Reversible while the report window is open — the window is the 137 deadline, and a hand-in nobody can take back only buys a support 138 queue against an artifact this platform lets teams revise. 139 140 ⚠️ **The spent grading request is not refunded**, which is what 141 keeps finalise → reopen → finalise from buying a model call a 142 minute. 143 """ 144 resp = self._http.request( 145 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/reopen" 146 ) 147 _raise_for_status(resp) 148 return ReportStanding.model_validate(resp.json()) 149 150 def request_grading(self, competition_id: str, challenge_id: str) -> ReportStanding: 151 """Ask for the current version to be judged now, without handing in. 152 153 Costed: the grading due-list only ever holds a report's *current* 154 version, so drafting is one model call per quiet spell however 155 many times a team saves, while save-then-request is one per save. 156 ``ReportStanding.grade_requests_limit`` is the bound and **``0`` 157 is unlimited**. 158 159 ⚠️ **Pressing it twice about one version is a success**, not a 160 second request — the stamp is on the version, so nothing more is 161 spent. 162 """ 163 resp = self._http.request( 164 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grade-now" 165 ) 166 _raise_for_status(resp) 167 return ReportStanding.model_validate(resp.json()) 168 169 # -- the appendix ------------------------------------------------------ 170 # 171 # ⚠️ **Attachments are evidence, never the answer.** Grading reads 172 # the report *text* — the sentinel extraction is a regex over it and 173 # the narrative grader is handed its prose — so a flag legible only 174 # inside a screenshot scores nothing. Upload the screenshot *and* 175 # write the value and the finding out in the report. 176 177 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 178 """This engagement's appendix, oldest first, plus how many more it may take.""" 179 resp = self._http.request( 180 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 181 ) 182 _raise_for_status(resp) 183 return ReportAttachmentsResponse.model_validate(resp.json()) 184 185 def upload_attachment( 186 self, 187 competition_id: str, 188 challenge_id: str, 189 *, 190 content: bytes, 191 filename: str = "evidence.png", 192 content_type: str = "application/octet-stream", 193 ) -> ReportAttachmentInfo: 194 """Add one evidence file to this engagement's appendix. 195 196 The server sniffs the bytes, so ``filename`` and ``content_type`` 197 fill the multipart envelope and decide nothing: real image bytes 198 get an image type and may render inline for the organiser, 199 anything else is stored opaque and served as a download. 200 201 Refused when the deployment has no object storage configured 202 (``503 storage_unavailable``) — the report itself still saves, 203 it just takes text only. 204 """ 205 resp = self._http.request( 206 "POST", 207 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 208 files={"file": (filename, content, content_type)}, 209 ) 210 _raise_for_status(resp) 211 return ReportAttachmentInfo.model_validate(resp.json()) 212 213 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 214 """Fetch one of this team's own attachments. 215 216 Streamed through the platform rather than from a bucket URL, so 217 the object store needs no public-read policy. 218 """ 219 resp = self._http.request( 220 "GET", 221 f"/competitions/{competition_id}/challenges/{challenge_id}" 222 f"/report/attachments/{attachment_id}", 223 ) 224 _raise_for_status(resp) 225 return resp.content 226 227 def delete_attachment( 228 self, competition_id: str, challenge_id: str, attachment_id: str 229 ) -> ReportAttachmentsResponse: 230 """Remove one attachment. Returns the appendix as it now stands. 231 232 A hard delete: the reason a team removes a file is that it should 233 not have been uploaded, and keeping the bytes would answer that 234 request with a lie. 235 """ 236 resp = self._http.request( 237 "DELETE", 238 f"/competitions/{competition_id}/challenges/{challenge_id}" 239 f"/report/attachments/{attachment_id}", 240 ) 241 _raise_for_status(resp) 242 return ReportAttachmentsResponse.model_validate(resp.json())
20class ReportsResource: 21 """One revisable report per (team, competition, challenge). 22 23 A GAUNTLET range is one estate with its own kill chain, so an event 24 curating four of them is four engagements and four write-ups. The 25 competition still owns the deadline; the artifact is per range. 26 27 ⚠️ **The last version saved is the one that gets graded**, and every 28 version is kept for the appeal. So :meth:`save` sends the whole 29 document each time — there is no append — and an empty one is 30 refused rather than stored, because an accidental blank save would 31 otherwise replace the artifact the team is scored on. 32 """ 33 34 def __init__(self, http: BaseHttpClient) -> None: 35 self._http = http 36 37 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 38 """Save a new version; get back what it wrote and what it credited. 39 40 Refused outside the competition's play sessions and for a 41 disqualified team, exactly like every other progress path — the 42 report *is* this format's progress path. 43 44 ``newly_credited`` counts the sentinels quoted in **this** save 45 that the team did not already hold, which is the whole feedback 46 channel the format allows: enough to learn the last edit was 47 worth something, never enough to learn how many exist. 48 """ 49 resp = self._http.request( 50 "PUT", 51 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 52 json={"content": content}, 53 ) 54 _raise_for_status(resp) 55 return ReportSaveResponse.model_validate(resp.json()) 56 57 def mine(self, competition_id: str) -> MyReportsResponse: 58 """Which ranges this team has written up, and how much of each. 59 60 Only ranges with a saved report — the ones not started are the 61 competition's challenge list minus these, which the caller 62 already holds. 63 """ 64 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 65 _raise_for_status(resp) 66 return MyReportsResponse.model_validate(resp.json()) 67 68 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 69 """This team's current report on one range — the highest version. 70 71 **404 when nothing has been saved**, rather than an empty 72 report: a blank body would be indistinguishable from a saved 73 empty one, and the write path refuses those precisely so the two 74 remain different facts. 75 """ 76 resp = self._http.request( 77 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 78 ) 79 _raise_for_status(resp) 80 return ReportInfo.model_validate(resp.json()) 81 82 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 83 """Every version saved for one range, newest first, with bodies.""" 84 resp = self._http.request( 85 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 86 ) 87 _raise_for_status(resp) 88 return ReportVersionsResponse.model_validate(resp.json()) 89 90 def grades(self, competition_id: str, challenge_id: str) -> MyReportGradesResponse: 91 """How each graded version of this team's report on one range did. 92 93 Per version: the points, the counts, and each finding the team holds 94 marked ``accurate`` / ``inaccurate`` / ``not_mentioned``. Only 95 findings the team already reached appear, and no answer value ever 96 does. 97 98 **403 ``report_grades_withheld``** while the competition does not 99 show its players their grades — a silent exercise, by default, until 100 the report locks. 101 """ 102 resp = self._http.request( 103 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grades" 104 ) 105 _raise_for_status(resp) 106 return MyReportGradesResponse.model_validate(resp.json()) 107 108 # -- 交卷, and asking to be judged now --------------------------------- 109 # 110 # ⚠️ **Two verbs, not one with a flag.** They are separately 111 # reachable in both directions: a team may want a verdict on a draft 112 # it fully intends to keep editing, and a team that has run out of 113 # grading requests must still be able to hand its work in. One call 114 # with ``finalize=True`` would make the action that must never be 115 # refused draw on the quota that can refuse it. 116 117 def finalize(self, competition_id: str, challenge_id: str) -> ReportStanding: 118 """交卷 — declare the current version this range's deliverable. 119 120 The save route then refuses a new version until :meth:`reopen`, 121 and, quota permitting, the report is judged now rather than when 122 the quiet window elapses. 123 124 ⚠️ **Never refused for want of quota.** Past the bound this still 125 hands the report in and the ordinary quiet window grades it, 126 which it must — a finalised report cannot be saved again. 127 """ 128 resp = self._http.request( 129 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/finalize" 130 ) 131 _raise_for_status(resp) 132 return ReportStanding.model_validate(resp.json()) 133 134 def reopen(self, competition_id: str, challenge_id: str) -> ReportStanding: 135 """Withdraw 交卷 so the team can save again. 136 137 Reversible while the report window is open — the window is the 138 deadline, and a hand-in nobody can take back only buys a support 139 queue against an artifact this platform lets teams revise. 140 141 ⚠️ **The spent grading request is not refunded**, which is what 142 keeps finalise → reopen → finalise from buying a model call a 143 minute. 144 """ 145 resp = self._http.request( 146 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/reopen" 147 ) 148 _raise_for_status(resp) 149 return ReportStanding.model_validate(resp.json()) 150 151 def request_grading(self, competition_id: str, challenge_id: str) -> ReportStanding: 152 """Ask for the current version to be judged now, without handing in. 153 154 Costed: the grading due-list only ever holds a report's *current* 155 version, so drafting is one model call per quiet spell however 156 many times a team saves, while save-then-request is one per save. 157 ``ReportStanding.grade_requests_limit`` is the bound and **``0`` 158 is unlimited**. 159 160 ⚠️ **Pressing it twice about one version is a success**, not a 161 second request — the stamp is on the version, so nothing more is 162 spent. 163 """ 164 resp = self._http.request( 165 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grade-now" 166 ) 167 _raise_for_status(resp) 168 return ReportStanding.model_validate(resp.json()) 169 170 # -- the appendix ------------------------------------------------------ 171 # 172 # ⚠️ **Attachments are evidence, never the answer.** Grading reads 173 # the report *text* — the sentinel extraction is a regex over it and 174 # the narrative grader is handed its prose — so a flag legible only 175 # inside a screenshot scores nothing. Upload the screenshot *and* 176 # write the value and the finding out in the report. 177 178 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 179 """This engagement's appendix, oldest first, plus how many more it may take.""" 180 resp = self._http.request( 181 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 182 ) 183 _raise_for_status(resp) 184 return ReportAttachmentsResponse.model_validate(resp.json()) 185 186 def upload_attachment( 187 self, 188 competition_id: str, 189 challenge_id: str, 190 *, 191 content: bytes, 192 filename: str = "evidence.png", 193 content_type: str = "application/octet-stream", 194 ) -> ReportAttachmentInfo: 195 """Add one evidence file to this engagement's appendix. 196 197 The server sniffs the bytes, so ``filename`` and ``content_type`` 198 fill the multipart envelope and decide nothing: real image bytes 199 get an image type and may render inline for the organiser, 200 anything else is stored opaque and served as a download. 201 202 Refused when the deployment has no object storage configured 203 (``503 storage_unavailable``) — the report itself still saves, 204 it just takes text only. 205 """ 206 resp = self._http.request( 207 "POST", 208 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 209 files={"file": (filename, content, content_type)}, 210 ) 211 _raise_for_status(resp) 212 return ReportAttachmentInfo.model_validate(resp.json()) 213 214 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 215 """Fetch one of this team's own attachments. 216 217 Streamed through the platform rather than from a bucket URL, so 218 the object store needs no public-read policy. 219 """ 220 resp = self._http.request( 221 "GET", 222 f"/competitions/{competition_id}/challenges/{challenge_id}" 223 f"/report/attachments/{attachment_id}", 224 ) 225 _raise_for_status(resp) 226 return resp.content 227 228 def delete_attachment( 229 self, competition_id: str, challenge_id: str, attachment_id: str 230 ) -> ReportAttachmentsResponse: 231 """Remove one attachment. Returns the appendix as it now stands. 232 233 A hard delete: the reason a team removes a file is that it should 234 not have been uploaded, and keeping the bytes would answer that 235 request with a lie. 236 """ 237 resp = self._http.request( 238 "DELETE", 239 f"/competitions/{competition_id}/challenges/{challenge_id}" 240 f"/report/attachments/{attachment_id}", 241 ) 242 _raise_for_status(resp) 243 return ReportAttachmentsResponse.model_validate(resp.json())
One revisable report per (team, competition, challenge).
A GAUNTLET range is one estate with its own kill chain, so an event curating four of them is four engagements and four write-ups. The competition still owns the deadline; the artifact is per range.
⚠️ The last version saved is the one that gets graded, and every
version is kept for the appeal. So save() sends the whole
document each time — there is no append — and an empty one is
refused rather than stored, because an accidental blank save would
otherwise replace the artifact the team is scored on.
37 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 38 """Save a new version; get back what it wrote and what it credited. 39 40 Refused outside the competition's play sessions and for a 41 disqualified team, exactly like every other progress path — the 42 report *is* this format's progress path. 43 44 ``newly_credited`` counts the sentinels quoted in **this** save 45 that the team did not already hold, which is the whole feedback 46 channel the format allows: enough to learn the last edit was 47 worth something, never enough to learn how many exist. 48 """ 49 resp = self._http.request( 50 "PUT", 51 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 52 json={"content": content}, 53 ) 54 _raise_for_status(resp) 55 return ReportSaveResponse.model_validate(resp.json())
Save a new version; get back what it wrote and what it credited.
Refused outside the competition's play sessions and for a disqualified team, exactly like every other progress path — the report is this format's progress path.
newly_credited counts the sentinels quoted in this save
that the team did not already hold, which is the whole feedback
channel the format allows: enough to learn the last edit was
worth something, never enough to learn how many exist.
57 def mine(self, competition_id: str) -> MyReportsResponse: 58 """Which ranges this team has written up, and how much of each. 59 60 Only ranges with a saved report — the ones not started are the 61 competition's challenge list minus these, which the caller 62 already holds. 63 """ 64 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 65 _raise_for_status(resp) 66 return MyReportsResponse.model_validate(resp.json())
Which ranges this team has written up, and how much of each.
Only ranges with a saved report — the ones not started are the competition's challenge list minus these, which the caller already holds.
68 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 69 """This team's current report on one range — the highest version. 70 71 **404 when nothing has been saved**, rather than an empty 72 report: a blank body would be indistinguishable from a saved 73 empty one, and the write path refuses those precisely so the two 74 remain different facts. 75 """ 76 resp = self._http.request( 77 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 78 ) 79 _raise_for_status(resp) 80 return ReportInfo.model_validate(resp.json())
This team's current report on one range — the highest version.
404 when nothing has been saved, rather than an empty report: a blank body would be indistinguishable from a saved empty one, and the write path refuses those precisely so the two remain different facts.
82 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 83 """Every version saved for one range, newest first, with bodies.""" 84 resp = self._http.request( 85 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 86 ) 87 _raise_for_status(resp) 88 return ReportVersionsResponse.model_validate(resp.json())
Every version saved for one range, newest first, with bodies.
90 def grades(self, competition_id: str, challenge_id: str) -> MyReportGradesResponse: 91 """How each graded version of this team's report on one range did. 92 93 Per version: the points, the counts, and each finding the team holds 94 marked ``accurate`` / ``inaccurate`` / ``not_mentioned``. Only 95 findings the team already reached appear, and no answer value ever 96 does. 97 98 **403 ``report_grades_withheld``** while the competition does not 99 show its players their grades — a silent exercise, by default, until 100 the report locks. 101 """ 102 resp = self._http.request( 103 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grades" 104 ) 105 _raise_for_status(resp) 106 return MyReportGradesResponse.model_validate(resp.json())
How each graded version of this team's report on one range did.
Per version: the points, the counts, and each finding the team holds
marked accurate / inaccurate / not_mentioned. Only
findings the team already reached appear, and no answer value ever
does.
403 report_grades_withheld while the competition does not
show its players their grades — a silent exercise, by default, until
the report locks.
117 def finalize(self, competition_id: str, challenge_id: str) -> ReportStanding: 118 """交卷 — declare the current version this range's deliverable. 119 120 The save route then refuses a new version until :meth:`reopen`, 121 and, quota permitting, the report is judged now rather than when 122 the quiet window elapses. 123 124 ⚠️ **Never refused for want of quota.** Past the bound this still 125 hands the report in and the ordinary quiet window grades it, 126 which it must — a finalised report cannot be saved again. 127 """ 128 resp = self._http.request( 129 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/finalize" 130 ) 131 _raise_for_status(resp) 132 return ReportStanding.model_validate(resp.json())
交卷 — declare the current version this range's deliverable.
The save route then refuses a new version until reopen(),
and, quota permitting, the report is judged now rather than when
the quiet window elapses.
⚠️ Never refused for want of quota. Past the bound this still hands the report in and the ordinary quiet window grades it, which it must — a finalised report cannot be saved again.
134 def reopen(self, competition_id: str, challenge_id: str) -> ReportStanding: 135 """Withdraw 交卷 so the team can save again. 136 137 Reversible while the report window is open — the window is the 138 deadline, and a hand-in nobody can take back only buys a support 139 queue against an artifact this platform lets teams revise. 140 141 ⚠️ **The spent grading request is not refunded**, which is what 142 keeps finalise → reopen → finalise from buying a model call a 143 minute. 144 """ 145 resp = self._http.request( 146 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/reopen" 147 ) 148 _raise_for_status(resp) 149 return ReportStanding.model_validate(resp.json())
Withdraw 交卷 so the team can save again.
Reversible while the report window is open — the window is the deadline, and a hand-in nobody can take back only buys a support queue against an artifact this platform lets teams revise.
⚠️ The spent grading request is not refunded, which is what keeps finalise → reopen → finalise from buying a model call a minute.
151 def request_grading(self, competition_id: str, challenge_id: str) -> ReportStanding: 152 """Ask for the current version to be judged now, without handing in. 153 154 Costed: the grading due-list only ever holds a report's *current* 155 version, so drafting is one model call per quiet spell however 156 many times a team saves, while save-then-request is one per save. 157 ``ReportStanding.grade_requests_limit`` is the bound and **``0`` 158 is unlimited**. 159 160 ⚠️ **Pressing it twice about one version is a success**, not a 161 second request — the stamp is on the version, so nothing more is 162 spent. 163 """ 164 resp = self._http.request( 165 "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grade-now" 166 ) 167 _raise_for_status(resp) 168 return ReportStanding.model_validate(resp.json())
Ask for the current version to be judged now, without handing in.
Costed: the grading due-list only ever holds a report's current
version, so drafting is one model call per quiet spell however
many times a team saves, while save-then-request is one per save.
ReportStanding.grade_requests_limit is the bound and 0
is unlimited.
⚠️ Pressing it twice about one version is a success, not a second request — the stamp is on the version, so nothing more is spent.
178 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 179 """This engagement's appendix, oldest first, plus how many more it may take.""" 180 resp = self._http.request( 181 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 182 ) 183 _raise_for_status(resp) 184 return ReportAttachmentsResponse.model_validate(resp.json())
This engagement's appendix, oldest first, plus how many more it may take.
186 def upload_attachment( 187 self, 188 competition_id: str, 189 challenge_id: str, 190 *, 191 content: bytes, 192 filename: str = "evidence.png", 193 content_type: str = "application/octet-stream", 194 ) -> ReportAttachmentInfo: 195 """Add one evidence file to this engagement's appendix. 196 197 The server sniffs the bytes, so ``filename`` and ``content_type`` 198 fill the multipart envelope and decide nothing: real image bytes 199 get an image type and may render inline for the organiser, 200 anything else is stored opaque and served as a download. 201 202 Refused when the deployment has no object storage configured 203 (``503 storage_unavailable``) — the report itself still saves, 204 it just takes text only. 205 """ 206 resp = self._http.request( 207 "POST", 208 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 209 files={"file": (filename, content, content_type)}, 210 ) 211 _raise_for_status(resp) 212 return ReportAttachmentInfo.model_validate(resp.json())
Add one evidence file to this engagement's appendix.
The server sniffs the bytes, so filename and content_type
fill the multipart envelope and decide nothing: real image bytes
get an image type and may render inline for the organiser,
anything else is stored opaque and served as a download.
Refused when the deployment has no object storage configured
(503 storage_unavailable) — the report itself still saves,
it just takes text only.
214 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 215 """Fetch one of this team's own attachments. 216 217 Streamed through the platform rather than from a bucket URL, so 218 the object store needs no public-read policy. 219 """ 220 resp = self._http.request( 221 "GET", 222 f"/competitions/{competition_id}/challenges/{challenge_id}" 223 f"/report/attachments/{attachment_id}", 224 ) 225 _raise_for_status(resp) 226 return resp.content
Fetch one of this team's own attachments.
Streamed through the platform rather than from a bucket URL, so the object store needs no public-read policy.
228 def delete_attachment( 229 self, competition_id: str, challenge_id: str, attachment_id: str 230 ) -> ReportAttachmentsResponse: 231 """Remove one attachment. Returns the appendix as it now stands. 232 233 A hard delete: the reason a team removes a file is that it should 234 not have been uploaded, and keeping the bytes would answer that 235 request with a lie. 236 """ 237 resp = self._http.request( 238 "DELETE", 239 f"/competitions/{competition_id}/challenges/{challenge_id}" 240 f"/report/attachments/{attachment_id}", 241 ) 242 _raise_for_status(resp) 243 return ReportAttachmentsResponse.model_validate(resp.json())
Remove one attachment. Returns the appendix as it now stands.
A hard delete: the reason a team removes a file is that it should not have been uploaded, and keeping the bytes would answer that request with a lie.