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