ctfy.sdk.admin_resources.authoring_review

One competition's uploads, for the people allowed to rule on them.

docs/community-authoring-plan.md P2.3, decision 16. Two resources:

  • admin.authoring_reviewers — who may rule on one event's uploads (super-admin only), the competition-admin grant's shape.
  • admin.competition_authored_challenges — the review itself, scoped to one event by the path: list, read, audit and rule. The platform queue (admin.authored_challenges) keeps everything else — publish, curate, push, smoke — because what a tree runs stays with a platform admin.
  1"""One competition's uploads, for the people allowed to rule on them.
  2
  3``docs/community-authoring-plan.md`` P2.3, decision 16. Two resources:
  4
  5* ``admin.authoring_reviewers`` — who may rule on one event's uploads
  6  (super-admin only), the competition-admin grant's shape.
  7* ``admin.competition_authored_challenges`` — the review itself, scoped
  8  to one event by the path: list, read, audit and rule. The platform
  9  queue (``admin.authored_challenges``) keeps everything else — publish,
 10  curate, push, smoke — because what a tree *runs* stays with a platform
 11  admin.
 12"""
 13
 14from __future__ import annotations
 15
 16import builtins
 17from typing import Literal
 18
 19from ctfy.core.enums import (
 20    AuthoredDestination,
 21    AuthoredFlowAction,
 22    AuthoredQueueOrder,
 23    AuthoredScoresChoice,
 24    AuthoredSmokeFilter,
 25)
 26from ctfy.core.state.models import ReviewStatus
 27from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 28from ctfy.sdk.base import BaseHttpClient
 29from ctfy.server.models import (
 30    AdminAuthoredChallengeDetail,
 31    AdminAuthoredChallengeRow,
 32    AdminAuthoredRevisionRow,
 33    AdminAuthoredVersionRow,
 34    AdminMaliceInfo,
 35    AdminMaliceStanding,
 36    AdminModelReview,
 37    AdminModelReviewInfo,
 38    AdminProbeInfo,
 39    AdminTaskInfo,
 40    AuthoredAuditReport,
 41    AuthoredChallengeFileBody,
 42    AuthoredFlow,
 43    AuthoredHistory,
 44    AuthoredPointsAtStake,
 45    AuthoredQueueCounts,
 46    AuthoredRevisionDiff,
 47    AuthoredSmokeLive,
 48    AuthoredSmokeLog,
 49    AuthoredSmokeLogSummary,
 50    AuthoredSmokeStepLines,
 51    AuthoredVersionDiff,
 52    AuthoredVersionMessage,
 53    AuthoringReviewerInfo,
 54    PrecheckRunsInfo,
 55)
 56
 57
 58class AdminAuthoringReviewersResource:
 59    """Grant / revoke the right to rule on one competition's uploads."""
 60
 61    def __init__(self, http: BaseHttpClient) -> None:
 62        self._http = http
 63
 64    def list(
 65        self, competition_id: str, offset: int = 0, limit: int = 50
 66    ) -> PagedList[AuthoringReviewerInfo]:
 67        """Who may rule on this competition's uploads."""
 68        resp = self._http.request(
 69            "GET",
 70            f"/admin/competitions/{competition_id}/authoring-reviewers",
 71            params={"offset": offset, "limit": limit},
 72        )
 73        _raise_for_status(resp)
 74        return _extract_items(resp.json(), AuthoringReviewerInfo)
 75
 76    def grant(
 77        self, competition_id: str, *, user_id: str = "", email: str = ""
 78    ) -> AuthoringReviewerInfo:
 79        """Grant it, to a user named by ``user_id`` or ``email``. The
 80        holder reaches this event's uploads and nothing else of it — not
 81        the organiser's console, not the entrants' registration details."""
 82        resp = self._http.request(
 83            "POST",
 84            f"/admin/competitions/{competition_id}/authoring-reviewers",
 85            json={"user_id": user_id, "email": email},
 86        )
 87        _raise_for_status(resp)
 88        return AuthoringReviewerInfo.model_validate(resp.json())
 89
 90    def revoke(self, competition_id: str, user_id: str) -> None:
 91        """Revoke it."""
 92        resp = self._http.request(
 93            "DELETE", f"/admin/competitions/{competition_id}/authoring-reviewers/{user_id}"
 94        )
 95        _raise_for_status(resp)
 96
 97
 98class AdminCompetitionAuthoredChallengesResource:
 99    """Read and rule on one competition's uploads.
100
101    ⚠️ The competition is the **grant**, not a filter: another event's
102    upload answers 404 here, and there is no parameter that widens the
103    listing.
104    """
105
106    def __init__(self, http: BaseHttpClient) -> None:
107        self._http = http
108
109    def list(
110        self,
111        competition_id: str,
112        *,
113        review_status: ReviewStatus | None = None,
114        smoke: AuthoredSmokeFilter | None = None,
115        revision: ReviewStatus | None = None,
116        order: AuthoredQueueOrder | None = None,
117        destination: AuthoredDestination | None = None,
118        offset: int = 0,
119        limit: int = 50,
120    ) -> PagedList[AdminAuthoredChallengeRow]:
121        """This event's uploads, oldest first. ``smoke``, ``revision``,
122        ``destination`` and ``order`` work as on the platform queue — except that each
123        uploader's ``author_reputation``, and so the ``reputation`` order,
124        counts only the uploads this reviewer may attribute to them."""
125        params: dict[str, object] = {"offset": offset, "limit": limit}
126        if review_status is not None:
127            params["review_status"] = review_status
128        if smoke is not None:
129            params["smoke"] = smoke
130        if revision is not None:
131            params["revision"] = revision
132        if order is not None:
133            params["order"] = order
134        if destination is not None:
135            params["destination"] = destination
136        resp = self._http.request(
137            "GET", f"/admin/competitions/{competition_id}/authored-challenges", params=params
138        )
139        _raise_for_status(resp)
140        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
141
142    def queue_counts(self, competition_id: str) -> AuthoredQueueCounts:
143        """This event's per-tab counts and longest wait (#2037 B1)."""
144        resp = self._http.request(
145            "GET", f"/admin/competitions/{competition_id}/authored-challenges/queue-counts"
146        )
147        _raise_for_status(resp)
148        return AuthoredQueueCounts.model_validate(resp.json())
149
150    def get(self, competition_id: str, challenge_id: str) -> AdminAuthoredChallengeDetail:
151        """One upload, with the paths, sizes and execution surface of
152        what is in it."""
153        resp = self._http.request(
154            "GET", f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
155        )
156        _raise_for_status(resp)
157        return AdminAuthoredChallengeDetail.model_validate(resp.json())
158
159    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
160        """Run the challenge audit over this upload. Read ``outcome``
161        before the counts — ``timed_out`` and ``failed`` are the audit
162        declining to answer, never a clean bill."""
163        resp = self._http.request(
164            "GET",
165            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
166        )
167        _raise_for_status(resp)
168        return AuthoredAuditReport.model_validate(resp.json())
169
170    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
171        """Where one of this event's uploads is in its lifecycle, and what the
172        caller can do next as the event's reviewer or organiser (#2147)."""
173        resp = self._http.request(
174            "GET",
175            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
176        )
177        _raise_for_status(resp)
178        return AuthoredFlow.model_validate(resp.json())
179
180    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
181        """The AI pre-check's managed-eval runs of one of this event's uploads'
182        pending versions, for its reviewers and organisers (#3189)."""
183        resp = self._http.request(
184            "GET",
185            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
186            "/precheck-runs",
187        )
188        _raise_for_status(resp)
189        return PrecheckRunsInfo.model_validate(resp.json())
190
191    def precheck_run_artifact(self, competition_id: str, challenge_id: str, run_id: str) -> bytes:
192        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes,
193        for this event's reviewers and organisers — never its author (#3260)."""
194        resp = self._http.request(
195            "GET",
196            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
197            f"/precheck-runs/{run_id}/artifact",
198        )
199        _raise_for_status(resp)
200        return bytes(resp.content)
201
202    def history(self, competition_id: str, challenge_id: str) -> AuthoredHistory:
203        """One of this event's uploads' review history, oldest first (#3093)."""
204        resp = self._http.request(
205            "GET",
206            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/history",
207        )
208        _raise_for_status(resp)
209        return AuthoredHistory.model_validate(resp.json())
210
211    def smoke_logs(
212        self, competition_id: str, challenge_id: str
213    ) -> builtins.list[AuthoredSmokeLogSummary]:
214        """This event's upload's smoke runs, newest first (#2024)."""
215        resp = self._http.request(
216            "GET",
217            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
218        )
219        _raise_for_status(resp)
220        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
221
222    def versions(
223        self, competition_id: str, challenge_id: str
224    ) -> builtins.list[AdminAuthoredVersionRow]:
225        """Every stored version of one of this event's uploads (#2037)."""
226        resp = self._http.request(
227            "GET",
228            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/versions",
229        )
230        _raise_for_status(resp)
231        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]
232
233    def version_diff(
234        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
235    ) -> AuthoredVersionDiff:
236        """Two versions of one of this event's uploads compared (#2037)."""
237        resp = self._http.request(
238            "GET",
239            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
240            "/versions/diff",
241            params={"from_version": from_version, "to_version": to_version},
242        )
243        _raise_for_status(resp)
244        return AuthoredVersionDiff.model_validate(resp.json())
245
246    def version_messages(
247        self, competition_id: str, challenge_id: str, version_id: str
248    ) -> builtins.list[AuthoredVersionMessage]:
249        """One version's thread, for this event's reviewers (#2037)."""
250        resp = self._http.request(
251            "GET",
252            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
253            f"/versions/{version_id}/messages",
254        )
255        _raise_for_status(resp)
256        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
257
258    def post_version_message(
259        self,
260        competition_id: str,
261        challenge_id: str,
262        version_id: str,
263        body: str,
264        *,
265        as_review_group: bool = True,
266    ) -> builtins.list[AuthoredVersionMessage]:
267        """Write to an upload's authors as this event's reviewer (#2037)."""
268        resp = self._http.request(
269            "POST",
270            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
271            f"/versions/{version_id}/messages",
272            json={"body": body, "as_review_group": as_review_group},
273        )
274        _raise_for_status(resp)
275        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
276
277    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
278        """One version of one of this event's uploads, as ``.tar.gz`` bytes."""
279        resp = self._http.request(
280            "GET",
281            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
282            f"/versions/{version_id}/archive",
283        )
284        _raise_for_status(resp)
285        return bytes(resp.content)
286
287    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
288        """One smoke run of one of this event's uploads (#2024)."""
289        resp = self._http.request(
290            "GET",
291            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
292            f"/smoke-logs/{log_id}",
293        )
294        _raise_for_status(resp)
295        return AuthoredSmokeLog.model_validate(resp.json())
296
297    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
298        """The smoke run of one of this event's uploads that is in flight (#2145)."""
299        resp = self._http.request(
300            "GET",
301            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live",
302        )
303        _raise_for_status(resp)
304        return AuthoredSmokeLive.model_validate(resp.json())
305
306    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
307        """Take back a waiting smoke run of one of this event's uploads (#2145)."""
308        resp = self._http.request(
309            "POST",
310            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
311            "/smoke-live/cancel",
312        )
313        _raise_for_status(resp)
314        return AuthoredSmokeLive.model_validate(resp.json())
315
316    def smoke_run_lines(
317        self,
318        competition_id: str,
319        challenge_id: str,
320        run_id: str,
321        key: str,
322        *,
323        before: int = 0,
324        after: int = 0,
325        limit: int = 200,
326    ) -> AuthoredSmokeStepLines:
327        """A window of one step's full output of one of this event's uploads (#2145)."""
328        resp = self._http.request(
329            "GET",
330            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
331            f"/smoke-runs/{run_id}/lines",
332            params={"step": key, "before": before, "after": after, "limit": limit},
333        )
334        _raise_for_status(resp)
335        return AuthoredSmokeStepLines.model_validate(resp.json())
336
337    def model_review(self, competition_id: str, challenge_id: str) -> AdminModelReviewInfo:
338        """The model's reading of one of this event's uploads (#1965 D) —
339        `admin.authored_challenges.model_review`'s answer, for its reviewers."""
340        resp = self._http.request(
341            "GET",
342            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
343        )
344        _raise_for_status(resp)
345        return AdminModelReviewInfo.model_validate(resp.json())
346
347    def assistant_review(self, competition_id: str, challenge_id: str) -> AdminProbeInfo:
348        """The AI pre-check of one of this event's uploads (#2830, #3053) —
349        every model's run, the numbers and the white-box findings, for its
350        reviewers and organisers."""
351        resp = self._http.request(
352            "GET",
353            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
354            "/assistant-review",
355        )
356        _raise_for_status(resp)
357        return AdminProbeInfo.model_validate(resp.json())
358
359    def rerun_assistant_review(self, competition_id: str, challenge_id: str) -> AdminTaskInfo:
360        """Pre-check the version under review again (#3053): the event's
361        organisers only, on an isolated node, counted against the version's
362        cap (``409 authoring_probe_budget_spent`` once it is spent)."""
363        resp = self._http.request(
364            "POST",
365            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
366            "/assistant-review",
367        )
368        _raise_for_status(resp)
369        return AdminTaskInfo.model_validate(resp.json())
370
371    def run_model_review(
372        self, competition_id: str, challenge_id: str, *, revision_id: str = ""
373    ) -> AdminTaskInfo:
374        """Ask the model to read one of this event's uploads — advice."""
375        params = {"revision_id": revision_id} if revision_id else None
376        resp = self._http.request(
377            "POST",
378            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
379            params=params,
380        )
381        _raise_for_status(resp)
382        return AdminTaskInfo.model_validate(resp.json())
383
384    def override_model_review(
385        self, competition_id: str, challenge_id: str, review_id: str, *, note: str
386    ) -> AdminModelReview:
387        """Say the text a review flagged is not an injection (``note`` is
388        the audit's *why*); a version it rejected is decided again."""
389        resp = self._http.request(
390            "POST",
391            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
392            f"/model-review/{review_id}/override",
393            json={"note": note},
394        )
395        _raise_for_status(resp)
396        return AdminModelReview.model_validate(resp.json())
397
398    def malice_review(self, competition_id: str, challenge_id: str) -> AdminMaliceInfo:
399        """The malice check of one of this event's uploads (#3211), for its
400        reviewers. Never calls a model."""
401        resp = self._http.request(
402            "GET",
403            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
404            "/malice-review",
405        )
406        _raise_for_status(resp)
407        return AdminMaliceInfo.model_validate(resp.json())
408
409    def override_malice_review(
410        self,
411        competition_id: str,
412        challenge_id: str,
413        review_id: str,
414        *,
415        decision: Literal["clear", "confirm"],
416        note: str,
417    ) -> AdminMaliceStanding:
418        """Rule on a malice hold of one of this event's uploads: ``clear``
419        or ``confirm``, with the audit's *why*."""
420        resp = self._http.request(
421            "POST",
422            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
423            f"/malice-review/{review_id}/override",
424            json={"decision": decision, "note": note},
425        )
426        _raise_for_status(resp)
427        return AdminMaliceStanding.model_validate(resp.json())
428
429    def read_file(
430        self, competition_id: str, challenge_id: str, path: str
431    ) -> AuthoredChallengeFileBody:
432        """One file's contents — attacker-authored text written to be
433        run. ``binary`` and ``truncated`` say what you are not seeing."""
434        resp = self._http.request(
435            "POST",
436            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/file",
437            json={"path": path},
438        )
439        _raise_for_status(resp)
440        return AuthoredChallengeFileBody.model_validate(resp.json())
441
442    def review(
443        self,
444        competition_id: str,
445        challenge_id: str,
446        *,
447        status: ReviewStatus,
448        note: str = "",
449    ) -> AdminAuthoredChallengeRow:
450        """Record a content verdict. ``rejected`` requires a note, and
451        ``pending`` withdraws a verdict. Publishing stays with a
452        platform admin, who reviews what the tree runs."""
453        resp = self._http.request(
454            "POST",
455            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/review",
456            json={"status": status, "note": note},
457        )
458        _raise_for_status(resp)
459        return AdminAuthoredChallengeRow.model_validate(resp.json())
460
461    def release_to_catalog(
462        self, competition_id: str, challenge_id: str
463    ) -> AdminAuthoredChallengeRow:
464        """Put an upload this competition curates in the public catalog
465        (#1989), from that competition — **platform admins only** (owner
466        decision on #2756): an organiser is refused 403. Refused (409, with reasons) for an
467        upload the competition does not curate, and for a tree an automatic
468        review released that no person has reviewed; confirming what such a
469        tree runs stays with a platform admin
470        (``admin.authored_challenges.release_to_catalog``)."""
471        resp = self._http.request(
472            "POST",
473            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
474            "/catalog-release",
475        )
476        _raise_for_status(resp)
477        return AdminAuthoredChallengeRow.model_validate(resp.json())
478
479    def points_at_stake(
480        self, competition_id: str, challenge_id: str, action: AuthoredFlowAction
481    ) -> AuthoredPointsAtStake:
482        """The organiser's read of what a removal would decide about the
483        points already scored on an upload this competition holds (#2991)."""
484        resp = self._http.request(
485            "GET",
486            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/points",
487            params={"action": action},
488        )
489        _raise_for_status(resp)
490        return AuthoredPointsAtStake.model_validate(resp.json())
491
492    def unpublish(
493        self,
494        competition_id: str,
495        challenge_id: str,
496        *,
497        reason: str,
498        scores: AuthoredScoresChoice | None = None,
499    ) -> AdminAuthoredChallengeRow:
500        """Take down an upload this competition holds, as one of its full
501        organisers (#1965 H) — with a reason, recorded on the upload's
502        publication history. There is no automatic take-down: a failing
503        re-check or players' reports alert the organisers, and this is what
504        they press. It leaves **every** competition that uses it, not only
505        this one (#2740 D7). Its solves keep their points unless ``scores`` says
506        otherwise; its authoring credit is void while it stays down. Refused (409, with reasons) for an upload
507        the competition does not hold; ``422`` for a blank reason.
508        ``scores`` (#2991): ``removed`` takes the points already scored on it
509        off the board; ``kept`` and ``None`` keep them."""
510        body: dict[str, str] = {"reason": reason}
511        if scores:
512            body["scores"] = scores
513        resp = self._http.request(
514            "POST",
515            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/unpublish",
516            json=body,
517        )
518        _raise_for_status(resp)
519        return AdminAuthoredChallengeRow.model_validate(resp.json())
520
521    def republish(
522        self, competition_id: str, challenge_id: str, *, reason: str = ""
523    ) -> AdminAuthoredChallengeRow:
524        """Restore an upload that was taken down (#1965 H) — the take-down
525        overruled, recorded beside it. Only a take-down is reversible here:
526        publishing a tree the first time stays a platform admin's act.
527        Refused (409, with reasons) when the upload's latest record is not a
528        take-down, or by any publish rule."""
529        resp = self._http.request(
530            "POST",
531            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/republish",
532            json={"reason": reason},
533        )
534        _raise_for_status(resp)
535        return AdminAuthoredChallengeRow.model_validate(resp.json())
536
537    def revisions(
538        self, competition_id: str, challenge_id: str
539    ) -> builtins.list[AdminAuthoredRevisionRow]:
540        """Every revision of one of this event's uploads, newest first."""
541        resp = self._http.request(
542            "GET",
543            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
544        )
545        _raise_for_status(resp)
546        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
547
548    def revision_diff(
549        self, competition_id: str, challenge_id: str, revision_id: str
550    ) -> AuthoredRevisionDiff:
551        """A proposed revision against the live tree (plan P3.1)."""
552        resp = self._http.request(
553            "GET",
554            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
555            f"/revisions/{revision_id}/diff",
556        )
557        _raise_for_status(resp)
558        return AuthoredRevisionDiff.model_validate(resp.json())
559
560    def read_revision_file(
561        self, competition_id: str, challenge_id: str, revision_id: str, path: str
562    ) -> AuthoredChallengeFileBody:
563        """One file of a revision, whole."""
564        resp = self._http.request(
565            "POST",
566            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
567            f"/revisions/{revision_id}/file",
568            json={"path": path},
569        )
570        _raise_for_status(resp)
571        return AuthoredChallengeFileBody.model_validate(resp.json())
572
573    def review_revision(
574        self,
575        competition_id: str,
576        challenge_id: str,
577        revision_id: str,
578        *,
579        status: ReviewStatus,
580        note: str = "",
581    ) -> AdminAuthoredRevisionRow:
582        """A content verdict on a revision of one of this event's uploads.
583        Applying it stays with a platform admin."""
584        resp = self._http.request(
585            "POST",
586            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
587            f"/revisions/{revision_id}/review",
588            json={"status": status, "note": note},
589        )
590        _raise_for_status(resp)
591        return AdminAuthoredRevisionRow.model_validate(resp.json())
class AdminAuthoringReviewersResource:
59class AdminAuthoringReviewersResource:
60    """Grant / revoke the right to rule on one competition's uploads."""
61
62    def __init__(self, http: BaseHttpClient) -> None:
63        self._http = http
64
65    def list(
66        self, competition_id: str, offset: int = 0, limit: int = 50
67    ) -> PagedList[AuthoringReviewerInfo]:
68        """Who may rule on this competition's uploads."""
69        resp = self._http.request(
70            "GET",
71            f"/admin/competitions/{competition_id}/authoring-reviewers",
72            params={"offset": offset, "limit": limit},
73        )
74        _raise_for_status(resp)
75        return _extract_items(resp.json(), AuthoringReviewerInfo)
76
77    def grant(
78        self, competition_id: str, *, user_id: str = "", email: str = ""
79    ) -> AuthoringReviewerInfo:
80        """Grant it, to a user named by ``user_id`` or ``email``. The
81        holder reaches this event's uploads and nothing else of it — not
82        the organiser's console, not the entrants' registration details."""
83        resp = self._http.request(
84            "POST",
85            f"/admin/competitions/{competition_id}/authoring-reviewers",
86            json={"user_id": user_id, "email": email},
87        )
88        _raise_for_status(resp)
89        return AuthoringReviewerInfo.model_validate(resp.json())
90
91    def revoke(self, competition_id: str, user_id: str) -> None:
92        """Revoke it."""
93        resp = self._http.request(
94            "DELETE", f"/admin/competitions/{competition_id}/authoring-reviewers/{user_id}"
95        )
96        _raise_for_status(resp)

Grant / revoke the right to rule on one competition's uploads.

AdminAuthoringReviewersResource(http: ctfy.sdk.base.BaseHttpClient)
62    def __init__(self, http: BaseHttpClient) -> None:
63        self._http = http
def list( self, competition_id: str, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AuthoringReviewerInfo]:
65    def list(
66        self, competition_id: str, offset: int = 0, limit: int = 50
67    ) -> PagedList[AuthoringReviewerInfo]:
68        """Who may rule on this competition's uploads."""
69        resp = self._http.request(
70            "GET",
71            f"/admin/competitions/{competition_id}/authoring-reviewers",
72            params={"offset": offset, "limit": limit},
73        )
74        _raise_for_status(resp)
75        return _extract_items(resp.json(), AuthoringReviewerInfo)

Who may rule on this competition's uploads.

def grant( self, competition_id: str, *, user_id: str = '', email: str = '') -> ctfy.server.models.AuthoringReviewerInfo:
77    def grant(
78        self, competition_id: str, *, user_id: str = "", email: str = ""
79    ) -> AuthoringReviewerInfo:
80        """Grant it, to a user named by ``user_id`` or ``email``. The
81        holder reaches this event's uploads and nothing else of it — not
82        the organiser's console, not the entrants' registration details."""
83        resp = self._http.request(
84            "POST",
85            f"/admin/competitions/{competition_id}/authoring-reviewers",
86            json={"user_id": user_id, "email": email},
87        )
88        _raise_for_status(resp)
89        return AuthoringReviewerInfo.model_validate(resp.json())

Grant it, to a user named by user_id or email. The holder reaches this event's uploads and nothing else of it — not the organiser's console, not the entrants' registration details.

def revoke(self, competition_id: str, user_id: str) -> None:
91    def revoke(self, competition_id: str, user_id: str) -> None:
92        """Revoke it."""
93        resp = self._http.request(
94            "DELETE", f"/admin/competitions/{competition_id}/authoring-reviewers/{user_id}"
95        )
96        _raise_for_status(resp)

Revoke it.

class AdminCompetitionAuthoredChallengesResource:
 99class AdminCompetitionAuthoredChallengesResource:
100    """Read and rule on one competition's uploads.
101
102    ⚠️ The competition is the **grant**, not a filter: another event's
103    upload answers 404 here, and there is no parameter that widens the
104    listing.
105    """
106
107    def __init__(self, http: BaseHttpClient) -> None:
108        self._http = http
109
110    def list(
111        self,
112        competition_id: str,
113        *,
114        review_status: ReviewStatus | None = None,
115        smoke: AuthoredSmokeFilter | None = None,
116        revision: ReviewStatus | None = None,
117        order: AuthoredQueueOrder | None = None,
118        destination: AuthoredDestination | None = None,
119        offset: int = 0,
120        limit: int = 50,
121    ) -> PagedList[AdminAuthoredChallengeRow]:
122        """This event's uploads, oldest first. ``smoke``, ``revision``,
123        ``destination`` and ``order`` work as on the platform queue — except that each
124        uploader's ``author_reputation``, and so the ``reputation`` order,
125        counts only the uploads this reviewer may attribute to them."""
126        params: dict[str, object] = {"offset": offset, "limit": limit}
127        if review_status is not None:
128            params["review_status"] = review_status
129        if smoke is not None:
130            params["smoke"] = smoke
131        if revision is not None:
132            params["revision"] = revision
133        if order is not None:
134            params["order"] = order
135        if destination is not None:
136            params["destination"] = destination
137        resp = self._http.request(
138            "GET", f"/admin/competitions/{competition_id}/authored-challenges", params=params
139        )
140        _raise_for_status(resp)
141        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
142
143    def queue_counts(self, competition_id: str) -> AuthoredQueueCounts:
144        """This event's per-tab counts and longest wait (#2037 B1)."""
145        resp = self._http.request(
146            "GET", f"/admin/competitions/{competition_id}/authored-challenges/queue-counts"
147        )
148        _raise_for_status(resp)
149        return AuthoredQueueCounts.model_validate(resp.json())
150
151    def get(self, competition_id: str, challenge_id: str) -> AdminAuthoredChallengeDetail:
152        """One upload, with the paths, sizes and execution surface of
153        what is in it."""
154        resp = self._http.request(
155            "GET", f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
156        )
157        _raise_for_status(resp)
158        return AdminAuthoredChallengeDetail.model_validate(resp.json())
159
160    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
161        """Run the challenge audit over this upload. Read ``outcome``
162        before the counts — ``timed_out`` and ``failed`` are the audit
163        declining to answer, never a clean bill."""
164        resp = self._http.request(
165            "GET",
166            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
167        )
168        _raise_for_status(resp)
169        return AuthoredAuditReport.model_validate(resp.json())
170
171    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
172        """Where one of this event's uploads is in its lifecycle, and what the
173        caller can do next as the event's reviewer or organiser (#2147)."""
174        resp = self._http.request(
175            "GET",
176            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
177        )
178        _raise_for_status(resp)
179        return AuthoredFlow.model_validate(resp.json())
180
181    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
182        """The AI pre-check's managed-eval runs of one of this event's uploads'
183        pending versions, for its reviewers and organisers (#3189)."""
184        resp = self._http.request(
185            "GET",
186            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
187            "/precheck-runs",
188        )
189        _raise_for_status(resp)
190        return PrecheckRunsInfo.model_validate(resp.json())
191
192    def precheck_run_artifact(self, competition_id: str, challenge_id: str, run_id: str) -> bytes:
193        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes,
194        for this event's reviewers and organisers — never its author (#3260)."""
195        resp = self._http.request(
196            "GET",
197            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
198            f"/precheck-runs/{run_id}/artifact",
199        )
200        _raise_for_status(resp)
201        return bytes(resp.content)
202
203    def history(self, competition_id: str, challenge_id: str) -> AuthoredHistory:
204        """One of this event's uploads' review history, oldest first (#3093)."""
205        resp = self._http.request(
206            "GET",
207            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/history",
208        )
209        _raise_for_status(resp)
210        return AuthoredHistory.model_validate(resp.json())
211
212    def smoke_logs(
213        self, competition_id: str, challenge_id: str
214    ) -> builtins.list[AuthoredSmokeLogSummary]:
215        """This event's upload's smoke runs, newest first (#2024)."""
216        resp = self._http.request(
217            "GET",
218            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
219        )
220        _raise_for_status(resp)
221        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
222
223    def versions(
224        self, competition_id: str, challenge_id: str
225    ) -> builtins.list[AdminAuthoredVersionRow]:
226        """Every stored version of one of this event's uploads (#2037)."""
227        resp = self._http.request(
228            "GET",
229            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/versions",
230        )
231        _raise_for_status(resp)
232        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]
233
234    def version_diff(
235        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
236    ) -> AuthoredVersionDiff:
237        """Two versions of one of this event's uploads compared (#2037)."""
238        resp = self._http.request(
239            "GET",
240            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
241            "/versions/diff",
242            params={"from_version": from_version, "to_version": to_version},
243        )
244        _raise_for_status(resp)
245        return AuthoredVersionDiff.model_validate(resp.json())
246
247    def version_messages(
248        self, competition_id: str, challenge_id: str, version_id: str
249    ) -> builtins.list[AuthoredVersionMessage]:
250        """One version's thread, for this event's reviewers (#2037)."""
251        resp = self._http.request(
252            "GET",
253            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
254            f"/versions/{version_id}/messages",
255        )
256        _raise_for_status(resp)
257        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
258
259    def post_version_message(
260        self,
261        competition_id: str,
262        challenge_id: str,
263        version_id: str,
264        body: str,
265        *,
266        as_review_group: bool = True,
267    ) -> builtins.list[AuthoredVersionMessage]:
268        """Write to an upload's authors as this event's reviewer (#2037)."""
269        resp = self._http.request(
270            "POST",
271            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
272            f"/versions/{version_id}/messages",
273            json={"body": body, "as_review_group": as_review_group},
274        )
275        _raise_for_status(resp)
276        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
277
278    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
279        """One version of one of this event's uploads, as ``.tar.gz`` bytes."""
280        resp = self._http.request(
281            "GET",
282            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
283            f"/versions/{version_id}/archive",
284        )
285        _raise_for_status(resp)
286        return bytes(resp.content)
287
288    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
289        """One smoke run of one of this event's uploads (#2024)."""
290        resp = self._http.request(
291            "GET",
292            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
293            f"/smoke-logs/{log_id}",
294        )
295        _raise_for_status(resp)
296        return AuthoredSmokeLog.model_validate(resp.json())
297
298    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
299        """The smoke run of one of this event's uploads that is in flight (#2145)."""
300        resp = self._http.request(
301            "GET",
302            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live",
303        )
304        _raise_for_status(resp)
305        return AuthoredSmokeLive.model_validate(resp.json())
306
307    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
308        """Take back a waiting smoke run of one of this event's uploads (#2145)."""
309        resp = self._http.request(
310            "POST",
311            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
312            "/smoke-live/cancel",
313        )
314        _raise_for_status(resp)
315        return AuthoredSmokeLive.model_validate(resp.json())
316
317    def smoke_run_lines(
318        self,
319        competition_id: str,
320        challenge_id: str,
321        run_id: str,
322        key: str,
323        *,
324        before: int = 0,
325        after: int = 0,
326        limit: int = 200,
327    ) -> AuthoredSmokeStepLines:
328        """A window of one step's full output of one of this event's uploads (#2145)."""
329        resp = self._http.request(
330            "GET",
331            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
332            f"/smoke-runs/{run_id}/lines",
333            params={"step": key, "before": before, "after": after, "limit": limit},
334        )
335        _raise_for_status(resp)
336        return AuthoredSmokeStepLines.model_validate(resp.json())
337
338    def model_review(self, competition_id: str, challenge_id: str) -> AdminModelReviewInfo:
339        """The model's reading of one of this event's uploads (#1965 D) —
340        `admin.authored_challenges.model_review`'s answer, for its reviewers."""
341        resp = self._http.request(
342            "GET",
343            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
344        )
345        _raise_for_status(resp)
346        return AdminModelReviewInfo.model_validate(resp.json())
347
348    def assistant_review(self, competition_id: str, challenge_id: str) -> AdminProbeInfo:
349        """The AI pre-check of one of this event's uploads (#2830, #3053) —
350        every model's run, the numbers and the white-box findings, for its
351        reviewers and organisers."""
352        resp = self._http.request(
353            "GET",
354            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
355            "/assistant-review",
356        )
357        _raise_for_status(resp)
358        return AdminProbeInfo.model_validate(resp.json())
359
360    def rerun_assistant_review(self, competition_id: str, challenge_id: str) -> AdminTaskInfo:
361        """Pre-check the version under review again (#3053): the event's
362        organisers only, on an isolated node, counted against the version's
363        cap (``409 authoring_probe_budget_spent`` once it is spent)."""
364        resp = self._http.request(
365            "POST",
366            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
367            "/assistant-review",
368        )
369        _raise_for_status(resp)
370        return AdminTaskInfo.model_validate(resp.json())
371
372    def run_model_review(
373        self, competition_id: str, challenge_id: str, *, revision_id: str = ""
374    ) -> AdminTaskInfo:
375        """Ask the model to read one of this event's uploads — advice."""
376        params = {"revision_id": revision_id} if revision_id else None
377        resp = self._http.request(
378            "POST",
379            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
380            params=params,
381        )
382        _raise_for_status(resp)
383        return AdminTaskInfo.model_validate(resp.json())
384
385    def override_model_review(
386        self, competition_id: str, challenge_id: str, review_id: str, *, note: str
387    ) -> AdminModelReview:
388        """Say the text a review flagged is not an injection (``note`` is
389        the audit's *why*); a version it rejected is decided again."""
390        resp = self._http.request(
391            "POST",
392            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
393            f"/model-review/{review_id}/override",
394            json={"note": note},
395        )
396        _raise_for_status(resp)
397        return AdminModelReview.model_validate(resp.json())
398
399    def malice_review(self, competition_id: str, challenge_id: str) -> AdminMaliceInfo:
400        """The malice check of one of this event's uploads (#3211), for its
401        reviewers. Never calls a model."""
402        resp = self._http.request(
403            "GET",
404            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
405            "/malice-review",
406        )
407        _raise_for_status(resp)
408        return AdminMaliceInfo.model_validate(resp.json())
409
410    def override_malice_review(
411        self,
412        competition_id: str,
413        challenge_id: str,
414        review_id: str,
415        *,
416        decision: Literal["clear", "confirm"],
417        note: str,
418    ) -> AdminMaliceStanding:
419        """Rule on a malice hold of one of this event's uploads: ``clear``
420        or ``confirm``, with the audit's *why*."""
421        resp = self._http.request(
422            "POST",
423            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
424            f"/malice-review/{review_id}/override",
425            json={"decision": decision, "note": note},
426        )
427        _raise_for_status(resp)
428        return AdminMaliceStanding.model_validate(resp.json())
429
430    def read_file(
431        self, competition_id: str, challenge_id: str, path: str
432    ) -> AuthoredChallengeFileBody:
433        """One file's contents — attacker-authored text written to be
434        run. ``binary`` and ``truncated`` say what you are not seeing."""
435        resp = self._http.request(
436            "POST",
437            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/file",
438            json={"path": path},
439        )
440        _raise_for_status(resp)
441        return AuthoredChallengeFileBody.model_validate(resp.json())
442
443    def review(
444        self,
445        competition_id: str,
446        challenge_id: str,
447        *,
448        status: ReviewStatus,
449        note: str = "",
450    ) -> AdminAuthoredChallengeRow:
451        """Record a content verdict. ``rejected`` requires a note, and
452        ``pending`` withdraws a verdict. Publishing stays with a
453        platform admin, who reviews what the tree runs."""
454        resp = self._http.request(
455            "POST",
456            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/review",
457            json={"status": status, "note": note},
458        )
459        _raise_for_status(resp)
460        return AdminAuthoredChallengeRow.model_validate(resp.json())
461
462    def release_to_catalog(
463        self, competition_id: str, challenge_id: str
464    ) -> AdminAuthoredChallengeRow:
465        """Put an upload this competition curates in the public catalog
466        (#1989), from that competition — **platform admins only** (owner
467        decision on #2756): an organiser is refused 403. Refused (409, with reasons) for an
468        upload the competition does not curate, and for a tree an automatic
469        review released that no person has reviewed; confirming what such a
470        tree runs stays with a platform admin
471        (``admin.authored_challenges.release_to_catalog``)."""
472        resp = self._http.request(
473            "POST",
474            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
475            "/catalog-release",
476        )
477        _raise_for_status(resp)
478        return AdminAuthoredChallengeRow.model_validate(resp.json())
479
480    def points_at_stake(
481        self, competition_id: str, challenge_id: str, action: AuthoredFlowAction
482    ) -> AuthoredPointsAtStake:
483        """The organiser's read of what a removal would decide about the
484        points already scored on an upload this competition holds (#2991)."""
485        resp = self._http.request(
486            "GET",
487            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/points",
488            params={"action": action},
489        )
490        _raise_for_status(resp)
491        return AuthoredPointsAtStake.model_validate(resp.json())
492
493    def unpublish(
494        self,
495        competition_id: str,
496        challenge_id: str,
497        *,
498        reason: str,
499        scores: AuthoredScoresChoice | None = None,
500    ) -> AdminAuthoredChallengeRow:
501        """Take down an upload this competition holds, as one of its full
502        organisers (#1965 H) — with a reason, recorded on the upload's
503        publication history. There is no automatic take-down: a failing
504        re-check or players' reports alert the organisers, and this is what
505        they press. It leaves **every** competition that uses it, not only
506        this one (#2740 D7). Its solves keep their points unless ``scores`` says
507        otherwise; its authoring credit is void while it stays down. Refused (409, with reasons) for an upload
508        the competition does not hold; ``422`` for a blank reason.
509        ``scores`` (#2991): ``removed`` takes the points already scored on it
510        off the board; ``kept`` and ``None`` keep them."""
511        body: dict[str, str] = {"reason": reason}
512        if scores:
513            body["scores"] = scores
514        resp = self._http.request(
515            "POST",
516            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/unpublish",
517            json=body,
518        )
519        _raise_for_status(resp)
520        return AdminAuthoredChallengeRow.model_validate(resp.json())
521
522    def republish(
523        self, competition_id: str, challenge_id: str, *, reason: str = ""
524    ) -> AdminAuthoredChallengeRow:
525        """Restore an upload that was taken down (#1965 H) — the take-down
526        overruled, recorded beside it. Only a take-down is reversible here:
527        publishing a tree the first time stays a platform admin's act.
528        Refused (409, with reasons) when the upload's latest record is not a
529        take-down, or by any publish rule."""
530        resp = self._http.request(
531            "POST",
532            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/republish",
533            json={"reason": reason},
534        )
535        _raise_for_status(resp)
536        return AdminAuthoredChallengeRow.model_validate(resp.json())
537
538    def revisions(
539        self, competition_id: str, challenge_id: str
540    ) -> builtins.list[AdminAuthoredRevisionRow]:
541        """Every revision of one of this event's uploads, newest first."""
542        resp = self._http.request(
543            "GET",
544            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
545        )
546        _raise_for_status(resp)
547        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
548
549    def revision_diff(
550        self, competition_id: str, challenge_id: str, revision_id: str
551    ) -> AuthoredRevisionDiff:
552        """A proposed revision against the live tree (plan P3.1)."""
553        resp = self._http.request(
554            "GET",
555            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
556            f"/revisions/{revision_id}/diff",
557        )
558        _raise_for_status(resp)
559        return AuthoredRevisionDiff.model_validate(resp.json())
560
561    def read_revision_file(
562        self, competition_id: str, challenge_id: str, revision_id: str, path: str
563    ) -> AuthoredChallengeFileBody:
564        """One file of a revision, whole."""
565        resp = self._http.request(
566            "POST",
567            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
568            f"/revisions/{revision_id}/file",
569            json={"path": path},
570        )
571        _raise_for_status(resp)
572        return AuthoredChallengeFileBody.model_validate(resp.json())
573
574    def review_revision(
575        self,
576        competition_id: str,
577        challenge_id: str,
578        revision_id: str,
579        *,
580        status: ReviewStatus,
581        note: str = "",
582    ) -> AdminAuthoredRevisionRow:
583        """A content verdict on a revision of one of this event's uploads.
584        Applying it stays with a platform admin."""
585        resp = self._http.request(
586            "POST",
587            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
588            f"/revisions/{revision_id}/review",
589            json={"status": status, "note": note},
590        )
591        _raise_for_status(resp)
592        return AdminAuthoredRevisionRow.model_validate(resp.json())

Read and rule on one competition's uploads.

⚠️ The competition is the grant, not a filter: another event's upload answers 404 here, and there is no parameter that widens the listing.

AdminCompetitionAuthoredChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
107    def __init__(self, http: BaseHttpClient) -> None:
108        self._http = http
def list( self, competition_id: str, *, review_status: Optional[Literal['pending', 'approved', 'rejected']] = None, smoke: Union[Literal['not_run', 'in_flight'], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored'], NoneType] = None, revision: Optional[Literal['pending', 'approved', 'rejected']] = None, order: Optional[Literal['oldest', 'reputation']] = None, destination: Optional[Literal['public', 'eval_only']] = None, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AdminAuthoredChallengeRow]:
110    def list(
111        self,
112        competition_id: str,
113        *,
114        review_status: ReviewStatus | None = None,
115        smoke: AuthoredSmokeFilter | None = None,
116        revision: ReviewStatus | None = None,
117        order: AuthoredQueueOrder | None = None,
118        destination: AuthoredDestination | None = None,
119        offset: int = 0,
120        limit: int = 50,
121    ) -> PagedList[AdminAuthoredChallengeRow]:
122        """This event's uploads, oldest first. ``smoke``, ``revision``,
123        ``destination`` and ``order`` work as on the platform queue — except that each
124        uploader's ``author_reputation``, and so the ``reputation`` order,
125        counts only the uploads this reviewer may attribute to them."""
126        params: dict[str, object] = {"offset": offset, "limit": limit}
127        if review_status is not None:
128            params["review_status"] = review_status
129        if smoke is not None:
130            params["smoke"] = smoke
131        if revision is not None:
132            params["revision"] = revision
133        if order is not None:
134            params["order"] = order
135        if destination is not None:
136            params["destination"] = destination
137        resp = self._http.request(
138            "GET", f"/admin/competitions/{competition_id}/authored-challenges", params=params
139        )
140        _raise_for_status(resp)
141        return _extract_items(resp.json(), AdminAuthoredChallengeRow)

This event's uploads, oldest first. smoke, revision, destination and order work as on the platform queue — except that each uploader's author_reputation, and so the reputation order, counts only the uploads this reviewer may attribute to them.

def queue_counts( self, competition_id: str) -> ctfy.server.models.AuthoredQueueCounts:
143    def queue_counts(self, competition_id: str) -> AuthoredQueueCounts:
144        """This event's per-tab counts and longest wait (#2037 B1)."""
145        resp = self._http.request(
146            "GET", f"/admin/competitions/{competition_id}/authored-challenges/queue-counts"
147        )
148        _raise_for_status(resp)
149        return AuthoredQueueCounts.model_validate(resp.json())

This event's per-tab counts and longest wait (#2037 B1).

def get( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeDetail:
151    def get(self, competition_id: str, challenge_id: str) -> AdminAuthoredChallengeDetail:
152        """One upload, with the paths, sizes and execution surface of
153        what is in it."""
154        resp = self._http.request(
155            "GET", f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
156        )
157        _raise_for_status(resp)
158        return AdminAuthoredChallengeDetail.model_validate(resp.json())

One upload, with the paths, sizes and execution surface of what is in it.

def audit( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredAuditReport:
160    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
161        """Run the challenge audit over this upload. Read ``outcome``
162        before the counts — ``timed_out`` and ``failed`` are the audit
163        declining to answer, never a clean bill."""
164        resp = self._http.request(
165            "GET",
166            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
167        )
168        _raise_for_status(resp)
169        return AuthoredAuditReport.model_validate(resp.json())

Run the challenge audit over this upload. Read outcome before the counts — timed_out and failed are the audit declining to answer, never a clean bill.

def flow( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredFlow:
171    def flow(self, competition_id: str, challenge_id: str) -> AuthoredFlow:
172        """Where one of this event's uploads is in its lifecycle, and what the
173        caller can do next as the event's reviewer or organiser (#2147)."""
174        resp = self._http.request(
175            "GET",
176            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/flow",
177        )
178        _raise_for_status(resp)
179        return AuthoredFlow.model_validate(resp.json())

Where one of this event's uploads is in its lifecycle, and what the caller can do next as the event's reviewer or organiser (#2147).

def precheck_runs( self, competition_id: str, challenge_id: str) -> ctfy.server.models.PrecheckRunsInfo:
181    def precheck_runs(self, competition_id: str, challenge_id: str) -> PrecheckRunsInfo:
182        """The AI pre-check's managed-eval runs of one of this event's uploads'
183        pending versions, for its reviewers and organisers (#3189)."""
184        resp = self._http.request(
185            "GET",
186            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
187            "/precheck-runs",
188        )
189        _raise_for_status(resp)
190        return PrecheckRunsInfo.model_validate(resp.json())

The AI pre-check's managed-eval runs of one of this event's uploads' pending versions, for its reviewers and organisers (#3189).

def precheck_run_artifact(self, competition_id: str, challenge_id: str, run_id: str) -> bytes:
192    def precheck_run_artifact(self, competition_id: str, challenge_id: str, run_id: str) -> bytes:
193        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes,
194        for this event's reviewers and organisers — never its author (#3260)."""
195        resp = self._http.request(
196            "GET",
197            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
198            f"/precheck-runs/{run_id}/artifact",
199        )
200        _raise_for_status(resp)
201        return bytes(resp.content)

What one pre-check run's agent left behind, as .tar.gz bytes, for this event's reviewers and organisers — never its author (#3260).

def history( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredHistory:
203    def history(self, competition_id: str, challenge_id: str) -> AuthoredHistory:
204        """One of this event's uploads' review history, oldest first (#3093)."""
205        resp = self._http.request(
206            "GET",
207            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/history",
208        )
209        _raise_for_status(resp)
210        return AuthoredHistory.model_validate(resp.json())

One of this event's uploads' review history, oldest first (#3093).

def smoke_logs( self, competition_id: str, challenge_id: str) -> list[ctfy.server.models.AuthoredSmokeLogSummary]:
212    def smoke_logs(
213        self, competition_id: str, challenge_id: str
214    ) -> builtins.list[AuthoredSmokeLogSummary]:
215        """This event's upload's smoke runs, newest first (#2024)."""
216        resp = self._http.request(
217            "GET",
218            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-logs",
219        )
220        _raise_for_status(resp)
221        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]

This event's upload's smoke runs, newest first (#2024).

def versions( self, competition_id: str, challenge_id: str) -> list[ctfy.server.models.AdminAuthoredVersionRow]:
223    def versions(
224        self, competition_id: str, challenge_id: str
225    ) -> builtins.list[AdminAuthoredVersionRow]:
226        """Every stored version of one of this event's uploads (#2037)."""
227        resp = self._http.request(
228            "GET",
229            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/versions",
230        )
231        _raise_for_status(resp)
232        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]

Every stored version of one of this event's uploads (#2037).

def version_diff( self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str) -> ctfy.server.models.AuthoredVersionDiff:
234    def version_diff(
235        self, competition_id: str, challenge_id: str, *, from_version: str, to_version: str
236    ) -> AuthoredVersionDiff:
237        """Two versions of one of this event's uploads compared (#2037)."""
238        resp = self._http.request(
239            "GET",
240            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
241            "/versions/diff",
242            params={"from_version": from_version, "to_version": to_version},
243        )
244        _raise_for_status(resp)
245        return AuthoredVersionDiff.model_validate(resp.json())

Two versions of one of this event's uploads compared (#2037).

def version_messages( self, competition_id: str, challenge_id: str, version_id: str) -> list[ctfy.server.models.AuthoredVersionMessage]:
247    def version_messages(
248        self, competition_id: str, challenge_id: str, version_id: str
249    ) -> builtins.list[AuthoredVersionMessage]:
250        """One version's thread, for this event's reviewers (#2037)."""
251        resp = self._http.request(
252            "GET",
253            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
254            f"/versions/{version_id}/messages",
255        )
256        _raise_for_status(resp)
257        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

One version's thread, for this event's reviewers (#2037).

def post_version_message( self, competition_id: str, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True) -> list[ctfy.server.models.AuthoredVersionMessage]:
259    def post_version_message(
260        self,
261        competition_id: str,
262        challenge_id: str,
263        version_id: str,
264        body: str,
265        *,
266        as_review_group: bool = True,
267    ) -> builtins.list[AuthoredVersionMessage]:
268        """Write to an upload's authors as this event's reviewer (#2037)."""
269        resp = self._http.request(
270            "POST",
271            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
272            f"/versions/{version_id}/messages",
273            json={"body": body, "as_review_group": as_review_group},
274        )
275        _raise_for_status(resp)
276        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

Write to an upload's authors as this event's reviewer (#2037).

def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
278    def version_archive(self, competition_id: str, challenge_id: str, version_id: str) -> bytes:
279        """One version of one of this event's uploads, as ``.tar.gz`` bytes."""
280        resp = self._http.request(
281            "GET",
282            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
283            f"/versions/{version_id}/archive",
284        )
285        _raise_for_status(resp)
286        return bytes(resp.content)

One version of one of this event's uploads, as .tar.gz bytes.

def smoke_log( self, competition_id: str, challenge_id: str, log_id: str) -> ctfy.server.models.AuthoredSmokeLog:
288    def smoke_log(self, competition_id: str, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
289        """One smoke run of one of this event's uploads (#2024)."""
290        resp = self._http.request(
291            "GET",
292            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
293            f"/smoke-logs/{log_id}",
294        )
295        _raise_for_status(resp)
296        return AuthoredSmokeLog.model_validate(resp.json())

One smoke run of one of this event's uploads (#2024).

def smoke_live( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
298    def smoke_live(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
299        """The smoke run of one of this event's uploads that is in flight (#2145)."""
300        resp = self._http.request(
301            "GET",
302            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/smoke-live",
303        )
304        _raise_for_status(resp)
305        return AuthoredSmokeLive.model_validate(resp.json())

The smoke run of one of this event's uploads that is in flight (#2145).

def cancel_smoke( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
307    def cancel_smoke(self, competition_id: str, challenge_id: str) -> AuthoredSmokeLive:
308        """Take back a waiting smoke run of one of this event's uploads (#2145)."""
309        resp = self._http.request(
310            "POST",
311            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
312            "/smoke-live/cancel",
313        )
314        _raise_for_status(resp)
315        return AuthoredSmokeLive.model_validate(resp.json())

Take back a waiting smoke run of one of this event's uploads (#2145).

def smoke_run_lines( self, competition_id: str, challenge_id: str, run_id: str, key: str, *, before: int = 0, after: int = 0, limit: int = 200) -> ctfy.server.models.AuthoredSmokeStepLines:
317    def smoke_run_lines(
318        self,
319        competition_id: str,
320        challenge_id: str,
321        run_id: str,
322        key: str,
323        *,
324        before: int = 0,
325        after: int = 0,
326        limit: int = 200,
327    ) -> AuthoredSmokeStepLines:
328        """A window of one step's full output of one of this event's uploads (#2145)."""
329        resp = self._http.request(
330            "GET",
331            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
332            f"/smoke-runs/{run_id}/lines",
333            params={"step": key, "before": before, "after": after, "limit": limit},
334        )
335        _raise_for_status(resp)
336        return AuthoredSmokeStepLines.model_validate(resp.json())

A window of one step's full output of one of this event's uploads (#2145).

def model_review( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminModelReviewInfo:
338    def model_review(self, competition_id: str, challenge_id: str) -> AdminModelReviewInfo:
339        """The model's reading of one of this event's uploads (#1965 D) —
340        `admin.authored_challenges.model_review`'s answer, for its reviewers."""
341        resp = self._http.request(
342            "GET",
343            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
344        )
345        _raise_for_status(resp)
346        return AdminModelReviewInfo.model_validate(resp.json())

The model's reading of one of this event's uploads (#1965 D) — admin.authored_challenges.model_review's answer, for its reviewers.

def assistant_review( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminProbeInfo:
348    def assistant_review(self, competition_id: str, challenge_id: str) -> AdminProbeInfo:
349        """The AI pre-check of one of this event's uploads (#2830, #3053) —
350        every model's run, the numbers and the white-box findings, for its
351        reviewers and organisers."""
352        resp = self._http.request(
353            "GET",
354            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
355            "/assistant-review",
356        )
357        _raise_for_status(resp)
358        return AdminProbeInfo.model_validate(resp.json())

The AI pre-check of one of this event's uploads (#2830, #3053) — every model's run, the numbers and the white-box findings, for its reviewers and organisers.

def rerun_assistant_review( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
360    def rerun_assistant_review(self, competition_id: str, challenge_id: str) -> AdminTaskInfo:
361        """Pre-check the version under review again (#3053): the event's
362        organisers only, on an isolated node, counted against the version's
363        cap (``409 authoring_probe_budget_spent`` once it is spent)."""
364        resp = self._http.request(
365            "POST",
366            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
367            "/assistant-review",
368        )
369        _raise_for_status(resp)
370        return AdminTaskInfo.model_validate(resp.json())

Pre-check the version under review again (#3053): the event's organisers only, on an isolated node, counted against the version's cap (409 authoring_probe_budget_spent once it is spent).

def run_model_review( self, competition_id: str, challenge_id: str, *, revision_id: str = '') -> ctfy.server.models.AdminTaskInfo:
372    def run_model_review(
373        self, competition_id: str, challenge_id: str, *, revision_id: str = ""
374    ) -> AdminTaskInfo:
375        """Ask the model to read one of this event's uploads — advice."""
376        params = {"revision_id": revision_id} if revision_id else None
377        resp = self._http.request(
378            "POST",
379            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/model-review",
380            params=params,
381        )
382        _raise_for_status(resp)
383        return AdminTaskInfo.model_validate(resp.json())

Ask the model to read one of this event's uploads — advice.

def override_model_review( self, competition_id: str, challenge_id: str, review_id: str, *, note: str) -> ctfy.server.models.AdminModelReview:
385    def override_model_review(
386        self, competition_id: str, challenge_id: str, review_id: str, *, note: str
387    ) -> AdminModelReview:
388        """Say the text a review flagged is not an injection (``note`` is
389        the audit's *why*); a version it rejected is decided again."""
390        resp = self._http.request(
391            "POST",
392            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
393            f"/model-review/{review_id}/override",
394            json={"note": note},
395        )
396        _raise_for_status(resp)
397        return AdminModelReview.model_validate(resp.json())

Say the text a review flagged is not an injection (note is the audit's why); a version it rejected is decided again.

def malice_review( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminMaliceInfo:
399    def malice_review(self, competition_id: str, challenge_id: str) -> AdminMaliceInfo:
400        """The malice check of one of this event's uploads (#3211), for its
401        reviewers. Never calls a model."""
402        resp = self._http.request(
403            "GET",
404            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
405            "/malice-review",
406        )
407        _raise_for_status(resp)
408        return AdminMaliceInfo.model_validate(resp.json())

The malice check of one of this event's uploads (#3211), for its reviewers. Never calls a model.

def override_malice_review( self, competition_id: str, challenge_id: str, review_id: str, *, decision: Literal['clear', 'confirm'], note: str) -> ctfy.server.models.AdminMaliceStanding:
410    def override_malice_review(
411        self,
412        competition_id: str,
413        challenge_id: str,
414        review_id: str,
415        *,
416        decision: Literal["clear", "confirm"],
417        note: str,
418    ) -> AdminMaliceStanding:
419        """Rule on a malice hold of one of this event's uploads: ``clear``
420        or ``confirm``, with the audit's *why*."""
421        resp = self._http.request(
422            "POST",
423            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
424            f"/malice-review/{review_id}/override",
425            json={"decision": decision, "note": note},
426        )
427        _raise_for_status(resp)
428        return AdminMaliceStanding.model_validate(resp.json())

Rule on a malice hold of one of this event's uploads: clear or confirm, with the audit's why.

def read_file( self, competition_id: str, challenge_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
430    def read_file(
431        self, competition_id: str, challenge_id: str, path: str
432    ) -> AuthoredChallengeFileBody:
433        """One file's contents — attacker-authored text written to be
434        run. ``binary`` and ``truncated`` say what you are not seeing."""
435        resp = self._http.request(
436            "POST",
437            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/file",
438            json={"path": path},
439        )
440        _raise_for_status(resp)
441        return AuthoredChallengeFileBody.model_validate(resp.json())

One file's contents — attacker-authored text written to be run. binary and truncated say what you are not seeing.

def review( self, competition_id: str, challenge_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '') -> ctfy.server.models.AdminAuthoredChallengeRow:
443    def review(
444        self,
445        competition_id: str,
446        challenge_id: str,
447        *,
448        status: ReviewStatus,
449        note: str = "",
450    ) -> AdminAuthoredChallengeRow:
451        """Record a content verdict. ``rejected`` requires a note, and
452        ``pending`` withdraws a verdict. Publishing stays with a
453        platform admin, who reviews what the tree runs."""
454        resp = self._http.request(
455            "POST",
456            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/review",
457            json={"status": status, "note": note},
458        )
459        _raise_for_status(resp)
460        return AdminAuthoredChallengeRow.model_validate(resp.json())

Record a content verdict. rejected requires a note, and pending withdraws a verdict. Publishing stays with a platform admin, who reviews what the tree runs.

def release_to_catalog( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeRow:
462    def release_to_catalog(
463        self, competition_id: str, challenge_id: str
464    ) -> AdminAuthoredChallengeRow:
465        """Put an upload this competition curates in the public catalog
466        (#1989), from that competition — **platform admins only** (owner
467        decision on #2756): an organiser is refused 403. Refused (409, with reasons) for an
468        upload the competition does not curate, and for a tree an automatic
469        review released that no person has reviewed; confirming what such a
470        tree runs stays with a platform admin
471        (``admin.authored_challenges.release_to_catalog``)."""
472        resp = self._http.request(
473            "POST",
474            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
475            "/catalog-release",
476        )
477        _raise_for_status(resp)
478        return AdminAuthoredChallengeRow.model_validate(resp.json())

Put an upload this competition curates in the public catalog (#1989), from that competition — platform admins only (owner decision on #2756): an organiser is refused 403. Refused (409, with reasons) for an upload the competition does not curate, and for a tree an automatic review released that no person has reviewed; confirming what such a tree runs stays with a platform admin (admin.authored_challenges.release_to_catalog).

def points_at_stake( self, competition_id: str, challenge_id: str, action: Literal['start_smoke', 'smoke_passes', 'smoke_fails', 'smoke_errors', 'tree_refused', 'smoke_retries', 'recheck_fails', 'recheck_passes', 'reupload', 'resmoke', 'approve', 'reject', 'reopen_review', 'publish', 'unpublish', 'republish', 'catalog_release', 'catalog_unrelease', 'retire', 'unretire', 'delete', 'curate', 'uncurate', 'submit_revision', 'withdraw_revision', 'approve_revision', 'reject_revision', 'apply_revision']) -> ctfy.server.models.AuthoredPointsAtStake:
480    def points_at_stake(
481        self, competition_id: str, challenge_id: str, action: AuthoredFlowAction
482    ) -> AuthoredPointsAtStake:
483        """The organiser's read of what a removal would decide about the
484        points already scored on an upload this competition holds (#2991)."""
485        resp = self._http.request(
486            "GET",
487            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/points",
488            params={"action": action},
489        )
490        _raise_for_status(resp)
491        return AuthoredPointsAtStake.model_validate(resp.json())

The organiser's read of what a removal would decide about the points already scored on an upload this competition holds (#2991).

def unpublish( self, competition_id: str, challenge_id: str, *, reason: str, scores: Optional[Literal['kept', 'removed']] = None) -> ctfy.server.models.AdminAuthoredChallengeRow:
493    def unpublish(
494        self,
495        competition_id: str,
496        challenge_id: str,
497        *,
498        reason: str,
499        scores: AuthoredScoresChoice | None = None,
500    ) -> AdminAuthoredChallengeRow:
501        """Take down an upload this competition holds, as one of its full
502        organisers (#1965 H) — with a reason, recorded on the upload's
503        publication history. There is no automatic take-down: a failing
504        re-check or players' reports alert the organisers, and this is what
505        they press. It leaves **every** competition that uses it, not only
506        this one (#2740 D7). Its solves keep their points unless ``scores`` says
507        otherwise; its authoring credit is void while it stays down. Refused (409, with reasons) for an upload
508        the competition does not hold; ``422`` for a blank reason.
509        ``scores`` (#2991): ``removed`` takes the points already scored on it
510        off the board; ``kept`` and ``None`` keep them."""
511        body: dict[str, str] = {"reason": reason}
512        if scores:
513            body["scores"] = scores
514        resp = self._http.request(
515            "POST",
516            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/unpublish",
517            json=body,
518        )
519        _raise_for_status(resp)
520        return AdminAuthoredChallengeRow.model_validate(resp.json())

Take down an upload this competition holds, as one of its full organisers (#1965 H) — with a reason, recorded on the upload's publication history. There is no automatic take-down: a failing re-check or players' reports alert the organisers, and this is what they press. It leaves every competition that uses it, not only this one (#2740 D7). Its solves keep their points unless scores says otherwise; its authoring credit is void while it stays down. Refused (409, with reasons) for an upload the competition does not hold; 422 for a blank reason. scores (#2991): removed takes the points already scored on it off the board; kept and None keep them.

def republish( self, competition_id: str, challenge_id: str, *, reason: str = '') -> ctfy.server.models.AdminAuthoredChallengeRow:
522    def republish(
523        self, competition_id: str, challenge_id: str, *, reason: str = ""
524    ) -> AdminAuthoredChallengeRow:
525        """Restore an upload that was taken down (#1965 H) — the take-down
526        overruled, recorded beside it. Only a take-down is reversible here:
527        publishing a tree the first time stays a platform admin's act.
528        Refused (409, with reasons) when the upload's latest record is not a
529        take-down, or by any publish rule."""
530        resp = self._http.request(
531            "POST",
532            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/republish",
533            json={"reason": reason},
534        )
535        _raise_for_status(resp)
536        return AdminAuthoredChallengeRow.model_validate(resp.json())

Restore an upload that was taken down (#1965 H) — the take-down overruled, recorded beside it. Only a take-down is reversible here: publishing a tree the first time stays a platform admin's act. Refused (409, with reasons) when the upload's latest record is not a take-down, or by any publish rule.

def revisions( self, competition_id: str, challenge_id: str) -> list[ctfy.server.models.AdminAuthoredRevisionRow]:
538    def revisions(
539        self, competition_id: str, challenge_id: str
540    ) -> builtins.list[AdminAuthoredRevisionRow]:
541        """Every revision of one of this event's uploads, newest first."""
542        resp = self._http.request(
543            "GET",
544            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
545        )
546        _raise_for_status(resp)
547        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]

Every revision of one of this event's uploads, newest first.

def revision_diff( self, competition_id: str, challenge_id: str, revision_id: str) -> ctfy.server.models.AuthoredRevisionDiff:
549    def revision_diff(
550        self, competition_id: str, challenge_id: str, revision_id: str
551    ) -> AuthoredRevisionDiff:
552        """A proposed revision against the live tree (plan P3.1)."""
553        resp = self._http.request(
554            "GET",
555            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
556            f"/revisions/{revision_id}/diff",
557        )
558        _raise_for_status(resp)
559        return AuthoredRevisionDiff.model_validate(resp.json())

A proposed revision against the live tree (plan P3.1).

def read_revision_file( self, competition_id: str, challenge_id: str, revision_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
561    def read_revision_file(
562        self, competition_id: str, challenge_id: str, revision_id: str, path: str
563    ) -> AuthoredChallengeFileBody:
564        """One file of a revision, whole."""
565        resp = self._http.request(
566            "POST",
567            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
568            f"/revisions/{revision_id}/file",
569            json={"path": path},
570        )
571        _raise_for_status(resp)
572        return AuthoredChallengeFileBody.model_validate(resp.json())

One file of a revision, whole.

def review_revision( self, competition_id: str, challenge_id: str, revision_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '') -> ctfy.server.models.AdminAuthoredRevisionRow:
574    def review_revision(
575        self,
576        competition_id: str,
577        challenge_id: str,
578        revision_id: str,
579        *,
580        status: ReviewStatus,
581        note: str = "",
582    ) -> AdminAuthoredRevisionRow:
583        """A content verdict on a revision of one of this event's uploads.
584        Applying it stays with a platform admin."""
585        resp = self._http.request(
586            "POST",
587            f"/admin/competitions/{competition_id}/authored-challenges/{challenge_id}"
588            f"/revisions/{revision_id}/review",
589            json={"status": status, "note": note},
590        )
591        _raise_for_status(resp)
592        return AdminAuthoredRevisionRow.model_validate(resp.json())

A content verdict on a revision of one of this event's uploads. Applying it stays with a platform admin.