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
class AdminReportsResource:
 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.

AdminReportsResource(http: ctfy.sdk.base.BaseHttpClient)
31    def __init__(self, http: BaseHttpClient) -> None:
32        self._http = http
def pipeline(self) -> ctfy.server.models.ReportPipeline:
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.

def list( self, competition_id: str) -> ctfy.server.models.ReportRosterResponse:
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.

def get( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.ReportVersionsResponse:
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.

def grades( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.ReportGradesResponse:
 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.

def attachments( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.ReportAttachmentsResponse:
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.

def grade_now( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.ReportRosterRow:
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.

def claims( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.ReportClaimsPage:
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.

def regrade( self, competition_id: str, team_id: str, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
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.

def attachment_bytes( self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str) -> bytes:
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.