ctfy.sdk.admin_resources.reports
client.admin.reports — the organiser's view of the deliverable.
1"""``client.admin.reports`` — the organiser's view of the deliverable.""" 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 AdminTaskInfo, 9 ReportAttachmentsResponse, 10 ReportClaimsPage, 11 ReportGradesResponse, 12 ReportPipeline, 13 ReportRosterResponse, 14 ReportRosterRow, 15 ReportVersionsResponse, 16) 17 18 19class AdminReportsResource: 20 """Who has written a report, and what one team actually wrote. 21 22 Split for the same reason ``client.admin.patches`` is: the roster is 23 competition-wide and carries **no bodies** — an event accumulates 24 report versions all week, so a listing that shipped them would read 25 every one out of SQLite to render a table of lengths. :meth:`get` 26 carries them, one team at a time, which is what retaining every 27 version was for. 28 """ 29 30 def __init__(self, http: BaseHttpClient) -> None: 31 self._http = http 32 33 def pipeline(self) -> ReportPipeline: 34 """The whole deployment's grading backlog, in the job's walk order. 35 36 ⚠️ **A different question from :meth:`list`, not a wider one.** 37 The per-tick ceiling is spent across every exercise competition 38 in one pass, so one event's roster cannot tell "eighteen queued 39 and draining" from "eighteen queued behind two hundred of 40 somebody else's" — identical rows, and only the second is a 41 reason to act. 42 43 ``grading_enabled`` / ``grading_configured`` travel with the 44 numbers because a backlog nothing drains is byte-identical to a 45 busy one: the job leaves an ungradeable report due on purpose and 46 records ``succeeded`` while doing it. 47 """ 48 resp = self._http.request("GET", "/admin/report-queue") 49 _raise_for_status(resp) 50 return ReportPipeline.model_validate(resp.json()) 51 52 def list(self, competition_id: str) -> ReportRosterResponse: 53 """Every current report's status in one competition. 54 55 One row per ``(team, range)``: a team that wrote up three of the 56 event's four ranges is three rows. 57 58 ``missing_team_count`` is served rather than derived: subtracting 59 the roster from the team list client-side is arithmetic two 60 clients can do differently, and then two organisers disagree 61 about who has not written anything. 62 """ 63 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 64 _raise_for_status(resp) 65 return ReportRosterResponse.model_validate(resp.json()) 66 67 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 68 """One team's whole report history on one range, bodies included. 69 70 ⚠️ The bodies are **attacker-authored text**. Render them as 71 text, never as HTML, and never feed one back into a prompt 72 outside the bounded matching task the grader defines. 73 """ 74 resp = self._http.request( 75 "GET", 76 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 77 ) 78 _raise_for_status(resp) 79 return ReportVersionsResponse.model_validate(resp.json()) 80 81 def grades(self, competition_id: str, team_id: str, challenge_id: str) -> ReportGradesResponse: 82 """Every grading run on one team's write-up, per-fact verdicts included. 83 84 The structured judgement behind the Reporting column: which 85 findings were called accurate, which inaccurate, which the team 86 reached and never wrote down, and how many were claimed with 87 nothing behind them. 88 89 ⚠️ **Newest run first, and a run is not a version** — re-grading 90 an older draft to settle an appeal deliberately makes that run 91 the live verdict, so the list is ordered by when the judgement 92 was made, not by which draft it was about. Compare 93 ``report_version`` against the roster's ``version`` to see 94 whether a team has saved again since. 95 """ 96 resp = self._http.request( 97 "GET", 98 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grades", 99 ) 100 _raise_for_status(resp) 101 return ReportGradesResponse.model_validate(resp.json()) 102 103 def attachments( 104 self, competition_id: str, team_id: str, challenge_id: str 105 ) -> ReportAttachmentsResponse: 106 """One team's evidence appendix for one range. 107 108 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 109 unlike :meth:`get`. That one distinguishes "wrote nothing" from 110 "you typed the wrong team id"; by the time an organiser is here 111 the team has already been resolved by reading the report, and an 112 appendix with no files is the ordinary case. 113 """ 114 resp = self._http.request( 115 "GET", 116 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 117 ) 118 _raise_for_status(resp) 119 return ReportAttachmentsResponse.model_validate(resp.json()) 120 121 def grade_now(self, competition_id: str, team_id: str, challenge_id: str) -> ReportRosterRow: 122 """Put one team's current version at the front of the grading queue. 123 124 The organiser's twin of the player's own ``grade-now``, and it 125 does **not** draw the team's ``report_grade_requests_per_range`` 126 quota: that bound stops a squad spending the deployment's 127 account as fast as they can type, and an organiser is the 128 account. A team that has used its three requests otherwise 129 leaves an organiser with nothing but the quiet window. 130 131 ⚠️ **It stamps, it does not grade.** The verdict is produced by 132 the next tick of the grading loop, so the row comes back 133 ``queued`` — reporting it as judged is the receipt-versus-verdict 134 mistake ``POST /patches`` records. 135 136 ⚠️ **On an already-graded report this changes nothing**, and 137 deliberately answers 200 rather than refusing: the due-list is 138 ``NOT EXISTS`` on the report's own version, so a judged row is 139 never due again. Use :meth:`regrade` for that — the returned row 140 is what says which of the two states it is in. 141 """ 142 resp = self._http.request( 143 "POST", 144 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grade-now", 145 ) 146 _raise_for_status(resp) 147 return ReportRosterRow.model_validate(resp.json()) 148 149 def claims(self, competition_id: str, team_id: str, challenge_id: str) -> ReportClaimsPage: 150 """What this report claims that the platform could not place. 151 152 ``#1582``'s second operator question. ⚠️ **Read ``page.read`` 153 before reading ``page.rows``**: an empty list means the platform 154 found nothing it could not place, that nothing ever looked, or 155 that the model call was refused, and only the first is a fact 156 about the team. The flag is what separates them, and a caller 157 that renders the rows alone will eventually report a clean sheet 158 for a deployment with no gateway. 159 160 ⚠️ **Nothing here is a score.** Whether an unintended path is a 161 finding or a defect in the range is a judgement the platform 162 deliberately does not make. 163 """ 164 resp = self._http.request( 165 "GET", 166 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/claims", 167 ) 168 _raise_for_status(resp) 169 return ReportClaimsPage.model_validate(resp.json()) 170 171 def regrade(self, competition_id: str, team_id: str, challenge_id: str) -> AdminTaskInfo: 172 """Judge one team's current report **again**, as a background task. 173 174 The verb an appeal needs, and the one :meth:`grade_now` cannot 175 be: a report that already carries a verdict at its current 176 version is never due again, so stamping it is a permanent 177 silent no-op. This calls the grader directly. 178 179 ⚠️ **It spends a model call every call**, unconditionally — the 180 other two verbs on this queue are bounded by the due-list and 181 this one is not. ⚠️ Asynchronous because grading has a 420 s 182 ceiling; poll the returned task for the verdict. 183 """ 184 resp = self._http.request( 185 "POST", 186 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/regrade", 187 ) 188 _raise_for_status(resp) 189 return AdminTaskInfo.model_validate(resp.json()) 190 191 def attachment_bytes( 192 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 193 ) -> bytes: 194 """Fetch one team's attachment, as an organiser. 195 196 ⚠️ The bytes were chosen by an untrusted uploader. The platform 197 serves them ``nosniff`` + sandboxed and, for anything that is not 198 a real image, as a download — so treat a saved file the way you 199 would treat any artifact recovered from an engagement. 200 """ 201 resp = self._http.request( 202 "GET", 203 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 204 f"/attachments/{attachment_id}", 205 ) 206 _raise_for_status(resp) 207 return resp.content
20class AdminReportsResource: 21 """Who has written a report, and what one team actually wrote. 22 23 Split for the same reason ``client.admin.patches`` is: the roster is 24 competition-wide and carries **no bodies** — an event accumulates 25 report versions all week, so a listing that shipped them would read 26 every one out of SQLite to render a table of lengths. :meth:`get` 27 carries them, one team at a time, which is what retaining every 28 version was for. 29 """ 30 31 def __init__(self, http: BaseHttpClient) -> None: 32 self._http = http 33 34 def pipeline(self) -> ReportPipeline: 35 """The whole deployment's grading backlog, in the job's walk order. 36 37 ⚠️ **A different question from :meth:`list`, not a wider one.** 38 The per-tick ceiling is spent across every exercise competition 39 in one pass, so one event's roster cannot tell "eighteen queued 40 and draining" from "eighteen queued behind two hundred of 41 somebody else's" — identical rows, and only the second is a 42 reason to act. 43 44 ``grading_enabled`` / ``grading_configured`` travel with the 45 numbers because a backlog nothing drains is byte-identical to a 46 busy one: the job leaves an ungradeable report due on purpose and 47 records ``succeeded`` while doing it. 48 """ 49 resp = self._http.request("GET", "/admin/report-queue") 50 _raise_for_status(resp) 51 return ReportPipeline.model_validate(resp.json()) 52 53 def list(self, competition_id: str) -> ReportRosterResponse: 54 """Every current report's status in one competition. 55 56 One row per ``(team, range)``: a team that wrote up three of the 57 event's four ranges is three rows. 58 59 ``missing_team_count`` is served rather than derived: subtracting 60 the roster from the team list client-side is arithmetic two 61 clients can do differently, and then two organisers disagree 62 about who has not written anything. 63 """ 64 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 65 _raise_for_status(resp) 66 return ReportRosterResponse.model_validate(resp.json()) 67 68 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 69 """One team's whole report history on one range, bodies included. 70 71 ⚠️ The bodies are **attacker-authored text**. Render them as 72 text, never as HTML, and never feed one back into a prompt 73 outside the bounded matching task the grader defines. 74 """ 75 resp = self._http.request( 76 "GET", 77 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 78 ) 79 _raise_for_status(resp) 80 return ReportVersionsResponse.model_validate(resp.json()) 81 82 def grades(self, competition_id: str, team_id: str, challenge_id: str) -> ReportGradesResponse: 83 """Every grading run on one team's write-up, per-fact verdicts included. 84 85 The structured judgement behind the Reporting column: which 86 findings were called accurate, which inaccurate, which the team 87 reached and never wrote down, and how many were claimed with 88 nothing behind them. 89 90 ⚠️ **Newest run first, and a run is not a version** — re-grading 91 an older draft to settle an appeal deliberately makes that run 92 the live verdict, so the list is ordered by when the judgement 93 was made, not by which draft it was about. Compare 94 ``report_version`` against the roster's ``version`` to see 95 whether a team has saved again since. 96 """ 97 resp = self._http.request( 98 "GET", 99 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grades", 100 ) 101 _raise_for_status(resp) 102 return ReportGradesResponse.model_validate(resp.json()) 103 104 def attachments( 105 self, competition_id: str, team_id: str, challenge_id: str 106 ) -> ReportAttachmentsResponse: 107 """One team's evidence appendix for one range. 108 109 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 110 unlike :meth:`get`. That one distinguishes "wrote nothing" from 111 "you typed the wrong team id"; by the time an organiser is here 112 the team has already been resolved by reading the report, and an 113 appendix with no files is the ordinary case. 114 """ 115 resp = self._http.request( 116 "GET", 117 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 118 ) 119 _raise_for_status(resp) 120 return ReportAttachmentsResponse.model_validate(resp.json()) 121 122 def grade_now(self, competition_id: str, team_id: str, challenge_id: str) -> ReportRosterRow: 123 """Put one team's current version at the front of the grading queue. 124 125 The organiser's twin of the player's own ``grade-now``, and it 126 does **not** draw the team's ``report_grade_requests_per_range`` 127 quota: that bound stops a squad spending the deployment's 128 account as fast as they can type, and an organiser is the 129 account. A team that has used its three requests otherwise 130 leaves an organiser with nothing but the quiet window. 131 132 ⚠️ **It stamps, it does not grade.** The verdict is produced by 133 the next tick of the grading loop, so the row comes back 134 ``queued`` — reporting it as judged is the receipt-versus-verdict 135 mistake ``POST /patches`` records. 136 137 ⚠️ **On an already-graded report this changes nothing**, and 138 deliberately answers 200 rather than refusing: the due-list is 139 ``NOT EXISTS`` on the report's own version, so a judged row is 140 never due again. Use :meth:`regrade` for that — the returned row 141 is what says which of the two states it is in. 142 """ 143 resp = self._http.request( 144 "POST", 145 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grade-now", 146 ) 147 _raise_for_status(resp) 148 return ReportRosterRow.model_validate(resp.json()) 149 150 def claims(self, competition_id: str, team_id: str, challenge_id: str) -> ReportClaimsPage: 151 """What this report claims that the platform could not place. 152 153 ``#1582``'s second operator question. ⚠️ **Read ``page.read`` 154 before reading ``page.rows``**: an empty list means the platform 155 found nothing it could not place, that nothing ever looked, or 156 that the model call was refused, and only the first is a fact 157 about the team. The flag is what separates them, and a caller 158 that renders the rows alone will eventually report a clean sheet 159 for a deployment with no gateway. 160 161 ⚠️ **Nothing here is a score.** Whether an unintended path is a 162 finding or a defect in the range is a judgement the platform 163 deliberately does not make. 164 """ 165 resp = self._http.request( 166 "GET", 167 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/claims", 168 ) 169 _raise_for_status(resp) 170 return ReportClaimsPage.model_validate(resp.json()) 171 172 def regrade(self, competition_id: str, team_id: str, challenge_id: str) -> AdminTaskInfo: 173 """Judge one team's current report **again**, as a background task. 174 175 The verb an appeal needs, and the one :meth:`grade_now` cannot 176 be: a report that already carries a verdict at its current 177 version is never due again, so stamping it is a permanent 178 silent no-op. This calls the grader directly. 179 180 ⚠️ **It spends a model call every call**, unconditionally — the 181 other two verbs on this queue are bounded by the due-list and 182 this one is not. ⚠️ Asynchronous because grading has a 420 s 183 ceiling; poll the returned task for the verdict. 184 """ 185 resp = self._http.request( 186 "POST", 187 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/regrade", 188 ) 189 _raise_for_status(resp) 190 return AdminTaskInfo.model_validate(resp.json()) 191 192 def attachment_bytes( 193 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 194 ) -> bytes: 195 """Fetch one team's attachment, as an organiser. 196 197 ⚠️ The bytes were chosen by an untrusted uploader. The platform 198 serves them ``nosniff`` + sandboxed and, for anything that is not 199 a real image, as a download — so treat a saved file the way you 200 would treat any artifact recovered from an engagement. 201 """ 202 resp = self._http.request( 203 "GET", 204 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 205 f"/attachments/{attachment_id}", 206 ) 207 _raise_for_status(resp) 208 return resp.content
Who has written a report, and what one team actually wrote.
Split for the same reason client.admin.patches is: the roster is
competition-wide and carries no bodies — an event accumulates
report versions all week, so a listing that shipped them would read
every one out of SQLite to render a table of lengths. get()
carries them, one team at a time, which is what retaining every
version was for.
34 def pipeline(self) -> ReportPipeline: 35 """The whole deployment's grading backlog, in the job's walk order. 36 37 ⚠️ **A different question from :meth:`list`, not a wider one.** 38 The per-tick ceiling is spent across every exercise competition 39 in one pass, so one event's roster cannot tell "eighteen queued 40 and draining" from "eighteen queued behind two hundred of 41 somebody else's" — identical rows, and only the second is a 42 reason to act. 43 44 ``grading_enabled`` / ``grading_configured`` travel with the 45 numbers because a backlog nothing drains is byte-identical to a 46 busy one: the job leaves an ungradeable report due on purpose and 47 records ``succeeded`` while doing it. 48 """ 49 resp = self._http.request("GET", "/admin/report-queue") 50 _raise_for_status(resp) 51 return ReportPipeline.model_validate(resp.json())
The whole deployment's grading backlog, in the job's walk order.
⚠️ A different question from list(), not a wider one.
The per-tick ceiling is spent across every exercise competition
in one pass, so one event's roster cannot tell "eighteen queued
and draining" from "eighteen queued behind two hundred of
somebody else's" — identical rows, and only the second is a
reason to act.
grading_enabled / grading_configured travel with the
numbers because a backlog nothing drains is byte-identical to a
busy one: the job leaves an ungradeable report due on purpose and
records succeeded while doing it.
53 def list(self, competition_id: str) -> ReportRosterResponse: 54 """Every current report's status in one competition. 55 56 One row per ``(team, range)``: a team that wrote up three of the 57 event's four ranges is three rows. 58 59 ``missing_team_count`` is served rather than derived: subtracting 60 the roster from the team list client-side is arithmetic two 61 clients can do differently, and then two organisers disagree 62 about who has not written anything. 63 """ 64 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 65 _raise_for_status(resp) 66 return ReportRosterResponse.model_validate(resp.json())
Every current report's status in one competition.
One row per (team, range): a team that wrote up three of the
event's four ranges is three rows.
missing_team_count is served rather than derived: subtracting
the roster from the team list client-side is arithmetic two
clients can do differently, and then two organisers disagree
about who has not written anything.
68 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 69 """One team's whole report history on one range, bodies included. 70 71 ⚠️ The bodies are **attacker-authored text**. Render them as 72 text, never as HTML, and never feed one back into a prompt 73 outside the bounded matching task the grader defines. 74 """ 75 resp = self._http.request( 76 "GET", 77 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 78 ) 79 _raise_for_status(resp) 80 return ReportVersionsResponse.model_validate(resp.json())
One team's whole report history on one range, bodies included.
⚠️ The bodies are attacker-authored text. Render them as text, never as HTML, and never feed one back into a prompt outside the bounded matching task the grader defines.
82 def grades(self, competition_id: str, team_id: str, challenge_id: str) -> ReportGradesResponse: 83 """Every grading run on one team's write-up, per-fact verdicts included. 84 85 The structured judgement behind the Reporting column: which 86 findings were called accurate, which inaccurate, which the team 87 reached and never wrote down, and how many were claimed with 88 nothing behind them. 89 90 ⚠️ **Newest run first, and a run is not a version** — re-grading 91 an older draft to settle an appeal deliberately makes that run 92 the live verdict, so the list is ordered by when the judgement 93 was made, not by which draft it was about. Compare 94 ``report_version`` against the roster's ``version`` to see 95 whether a team has saved again since. 96 """ 97 resp = self._http.request( 98 "GET", 99 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grades", 100 ) 101 _raise_for_status(resp) 102 return ReportGradesResponse.model_validate(resp.json())
Every grading run on one team's write-up, per-fact verdicts included.
The structured judgement behind the Reporting column: which findings were called accurate, which inaccurate, which the team reached and never wrote down, and how many were claimed with nothing behind them.
⚠️ Newest run first, and a run is not a version — re-grading
an older draft to settle an appeal deliberately makes that run
the live verdict, so the list is ordered by when the judgement
was made, not by which draft it was about. Compare
report_version against the roster's version to see
whether a team has saved again since.
104 def attachments( 105 self, competition_id: str, team_id: str, challenge_id: str 106 ) -> ReportAttachmentsResponse: 107 """One team's evidence appendix for one range. 108 109 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 110 unlike :meth:`get`. That one distinguishes "wrote nothing" from 111 "you typed the wrong team id"; by the time an organiser is here 112 the team has already been resolved by reading the report, and an 113 appendix with no files is the ordinary case. 114 """ 115 resp = self._http.request( 116 "GET", 117 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 118 ) 119 _raise_for_status(resp) 120 return ReportAttachmentsResponse.model_validate(resp.json())
One team's evidence appendix for one range.
⚠️ Empty rather than 404 for a team that uploaded nothing,
unlike get(). That one distinguishes "wrote nothing" from
"you typed the wrong team id"; by the time an organiser is here
the team has already been resolved by reading the report, and an
appendix with no files is the ordinary case.
122 def grade_now(self, competition_id: str, team_id: str, challenge_id: str) -> ReportRosterRow: 123 """Put one team's current version at the front of the grading queue. 124 125 The organiser's twin of the player's own ``grade-now``, and it 126 does **not** draw the team's ``report_grade_requests_per_range`` 127 quota: that bound stops a squad spending the deployment's 128 account as fast as they can type, and an organiser is the 129 account. A team that has used its three requests otherwise 130 leaves an organiser with nothing but the quiet window. 131 132 ⚠️ **It stamps, it does not grade.** The verdict is produced by 133 the next tick of the grading loop, so the row comes back 134 ``queued`` — reporting it as judged is the receipt-versus-verdict 135 mistake ``POST /patches`` records. 136 137 ⚠️ **On an already-graded report this changes nothing**, and 138 deliberately answers 200 rather than refusing: the due-list is 139 ``NOT EXISTS`` on the report's own version, so a judged row is 140 never due again. Use :meth:`regrade` for that — the returned row 141 is what says which of the two states it is in. 142 """ 143 resp = self._http.request( 144 "POST", 145 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/grade-now", 146 ) 147 _raise_for_status(resp) 148 return ReportRosterRow.model_validate(resp.json())
Put one team's current version at the front of the grading queue.
The organiser's twin of the player's own grade-now, and it
does not draw the team's report_grade_requests_per_range
quota: that bound stops a squad spending the deployment's
account as fast as they can type, and an organiser is the
account. A team that has used its three requests otherwise
leaves an organiser with nothing but the quiet window.
⚠️ It stamps, it does not grade. The verdict is produced by
the next tick of the grading loop, so the row comes back
queued — reporting it as judged is the receipt-versus-verdict
mistake POST /patches records.
⚠️ On an already-graded report this changes nothing, and
deliberately answers 200 rather than refusing: the due-list is
NOT EXISTS on the report's own version, so a judged row is
never due again. Use regrade() for that — the returned row
is what says which of the two states it is in.
150 def claims(self, competition_id: str, team_id: str, challenge_id: str) -> ReportClaimsPage: 151 """What this report claims that the platform could not place. 152 153 ``#1582``'s second operator question. ⚠️ **Read ``page.read`` 154 before reading ``page.rows``**: an empty list means the platform 155 found nothing it could not place, that nothing ever looked, or 156 that the model call was refused, and only the first is a fact 157 about the team. The flag is what separates them, and a caller 158 that renders the rows alone will eventually report a clean sheet 159 for a deployment with no gateway. 160 161 ⚠️ **Nothing here is a score.** Whether an unintended path is a 162 finding or a defect in the range is a judgement the platform 163 deliberately does not make. 164 """ 165 resp = self._http.request( 166 "GET", 167 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/claims", 168 ) 169 _raise_for_status(resp) 170 return ReportClaimsPage.model_validate(resp.json())
What this report claims that the platform could not place.
#1582's second operator question. ⚠️ Read page.read
before reading page.rows: an empty list means the platform
found nothing it could not place, that nothing ever looked, or
that the model call was refused, and only the first is a fact
about the team. The flag is what separates them, and a caller
that renders the rows alone will eventually report a clean sheet
for a deployment with no gateway.
⚠️ Nothing here is a score. Whether an unintended path is a finding or a defect in the range is a judgement the platform deliberately does not make.
172 def regrade(self, competition_id: str, team_id: str, challenge_id: str) -> AdminTaskInfo: 173 """Judge one team's current report **again**, as a background task. 174 175 The verb an appeal needs, and the one :meth:`grade_now` cannot 176 be: a report that already carries a verdict at its current 177 version is never due again, so stamping it is a permanent 178 silent no-op. This calls the grader directly. 179 180 ⚠️ **It spends a model call every call**, unconditionally — the 181 other two verbs on this queue are bounded by the due-list and 182 this one is not. ⚠️ Asynchronous because grading has a 420 s 183 ceiling; poll the returned task for the verdict. 184 """ 185 resp = self._http.request( 186 "POST", 187 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/regrade", 188 ) 189 _raise_for_status(resp) 190 return AdminTaskInfo.model_validate(resp.json())
Judge one team's current report again, as a background task.
The verb an appeal needs, and the one grade_now() cannot
be: a report that already carries a verdict at its current
version is never due again, so stamping it is a permanent
silent no-op. This calls the grader directly.
⚠️ It spends a model call every call, unconditionally — the other two verbs on this queue are bounded by the due-list and this one is not. ⚠️ Asynchronous because grading has a 420 s ceiling; poll the returned task for the verdict.
192 def attachment_bytes( 193 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 194 ) -> bytes: 195 """Fetch one team's attachment, as an organiser. 196 197 ⚠️ The bytes were chosen by an untrusted uploader. The platform 198 serves them ``nosniff`` + sandboxed and, for anything that is not 199 a real image, as a download — so treat a saved file the way you 200 would treat any artifact recovered from an engagement. 201 """ 202 resp = self._http.request( 203 "GET", 204 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 205 f"/attachments/{attachment_id}", 206 ) 207 _raise_for_status(resp) 208 return resp.content
Fetch one team's attachment, as an organiser.
⚠️ The bytes were chosen by an untrusted uploader. The platform
serves them nosniff + sandboxed and, for anything that is not
a real image, as a download — so treat a saved file the way you
would treat any artifact recovered from an engagement.