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())
class ReportsResource:
 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.

ReportsResource(http: ctfy.sdk.base.BaseHttpClient)
34    def __init__(self, http: BaseHttpClient) -> None:
35        self._http = http
def save( self, competition_id: str, challenge_id: str, content: str) -> ctfy.server.models.ReportSaveResponse:
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.

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

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

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

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

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

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

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

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

def upload_attachment( self, competition_id: str, challenge_id: str, *, content: bytes, filename: str = 'evidence.png', content_type: str = 'application/octet-stream') -> ctfy.server.models.ReportAttachmentInfo:
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.

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

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