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

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:
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())

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:
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())

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:
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())

Every version saved for one range, newest first, with bodies.

def finalize( self, competition_id: str, challenge_id: str) -> ctfy.server.models.ReportStanding:
 98    def finalize(self, competition_id: str, challenge_id: str) -> ReportStanding:
 99        """交卷 — declare the current version this range's deliverable.
100
101        The save route then refuses a new version until :meth:`reopen`,
102        and, quota permitting, the report is judged now rather than when
103        the quiet window elapses.
104
105        ⚠️ **Never refused for want of quota.** Past the bound this still
106        hands the report in and the ordinary quiet window grades it,
107        which it must — a finalised report cannot be saved again.
108        """
109        resp = self._http.request(
110            "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/finalize"
111        )
112        _raise_for_status(resp)
113        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:
115    def reopen(self, competition_id: str, challenge_id: str) -> ReportStanding:
116        """Withdraw 交卷 so the team can save again.
117
118        Reversible while the report window is open — the window is the
119        deadline, and a hand-in nobody can take back only buys a support
120        queue against an artifact this platform lets teams revise.
121
122        ⚠️ **The spent grading request is not refunded**, which is what
123        keeps finalise → reopen → finalise from buying a model call a
124        minute.
125        """
126        resp = self._http.request(
127            "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/reopen"
128        )
129        _raise_for_status(resp)
130        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:
132    def request_grading(self, competition_id: str, challenge_id: str) -> ReportStanding:
133        """Ask for the current version to be judged now, without handing in.
134
135        Costed: the grading due-list only ever holds a report's *current*
136        version, so drafting is one model call per quiet spell however
137        many times a team saves, while save-then-request is one per save.
138        ``ReportStanding.grade_requests_limit`` is the bound and **``0``
139        is unlimited**.
140
141        ⚠️ **Pressing it twice about one version is a success**, not a
142        second request — the stamp is on the version, so nothing more is
143        spent.
144        """
145        resp = self._http.request(
146            "POST", f"/competitions/{competition_id}/challenges/{challenge_id}/report/grade-now"
147        )
148        _raise_for_status(resp)
149        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:
159    def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse:
160        """This engagement's appendix, oldest first, plus how many more it may take."""
161        resp = self._http.request(
162            "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments"
163        )
164        _raise_for_status(resp)
165        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:
167    def upload_attachment(
168        self,
169        competition_id: str,
170        challenge_id: str,
171        *,
172        content: bytes,
173        filename: str = "evidence.png",
174        content_type: str = "application/octet-stream",
175    ) -> ReportAttachmentInfo:
176        """Add one evidence file to this engagement's appendix.
177
178        The server sniffs the bytes, so ``filename`` and ``content_type``
179        fill the multipart envelope and decide nothing: real image bytes
180        get an image type and may render inline for the organiser,
181        anything else is stored opaque and served as a download.
182
183        Refused when the deployment has no object storage configured
184        (``503 storage_unavailable``) — the report itself still saves,
185        it just takes text only.
186        """
187        resp = self._http.request(
188            "POST",
189            f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments",
190            files={"file": (filename, content, content_type)},
191        )
192        _raise_for_status(resp)
193        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:
195    def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes:
196        """Fetch one of this team's own attachments.
197
198        Streamed through the platform rather than from a bucket URL, so
199        the object store needs no public-read policy.
200        """
201        resp = self._http.request(
202            "GET",
203            f"/competitions/{competition_id}/challenges/{challenge_id}"
204            f"/report/attachments/{attachment_id}",
205        )
206        _raise_for_status(resp)
207        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:
209    def delete_attachment(
210        self, competition_id: str, challenge_id: str, attachment_id: str
211    ) -> ReportAttachmentsResponse:
212        """Remove one attachment. Returns the appendix as it now stands.
213
214        A hard delete: the reason a team removes a file is that it should
215        not have been uploaded, and keeping the bytes would answer that
216        request with a lie.
217        """
218        resp = self._http.request(
219            "DELETE",
220            f"/competitions/{competition_id}/challenges/{challenge_id}"
221            f"/report/attachments/{attachment_id}",
222        )
223        _raise_for_status(resp)
224        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.