icpc-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. icpc/__init__.py +32 -0
  2. icpc/api/__init__.py +18 -0
  3. icpc/api/common.py +97 -0
  4. icpc/api/contest.py +253 -0
  5. icpc/api/person.py +139 -0
  6. icpc/api/public.py +65 -0
  7. icpc/api/staff.py +83 -0
  8. icpc/api/team.py +335 -0
  9. icpc/auth/__init__.py +28 -0
  10. icpc/auth/cognito.py +142 -0
  11. icpc/auth/flows.py +314 -0
  12. icpc/auth/provider.py +29 -0
  13. icpc/auth/srp.py +185 -0
  14. icpc/auth/store.py +223 -0
  15. icpc/auth/tokens.py +86 -0
  16. icpc/cli/__init__.py +20 -0
  17. icpc/cli/columns.py +555 -0
  18. icpc/cli/main.py +1156 -0
  19. icpc/cli/render.py +160 -0
  20. icpc/config.py +57 -0
  21. icpc/errors.py +159 -0
  22. icpc/facade/__init__.py +6 -0
  23. icpc/facade/client.py +606 -0
  24. icpc/facade/domain.py +198 -0
  25. icpc/models/__init__.py +60 -0
  26. icpc/models/_generated.py +566 -0
  27. icpc/models/base.py +41 -0
  28. icpc/models/blobs.py +81 -0
  29. icpc/models/common.py +61 -0
  30. icpc/models/entities.py +522 -0
  31. icpc/models/enums.py +192 -0
  32. icpc/models/mixins.py +44 -0
  33. icpc/py.typed +0 -0
  34. icpc/search/__init__.py +99 -0
  35. icpc/search/_generated.py +1814 -0
  36. icpc/search/dsl.py +124 -0
  37. icpc/search/endpoint.py +173 -0
  38. icpc/search/fields.py +59 -0
  39. icpc/transport/__init__.py +29 -0
  40. icpc/transport/_shared.py +121 -0
  41. icpc/transport/async_client.py +120 -0
  42. icpc/transport/operation.py +139 -0
  43. icpc/transport/sync_client.py +121 -0
  44. icpc_api-0.1.0.dist-info/METADATA +143 -0
  45. icpc_api-0.1.0.dist-info/RECORD +50 -0
  46. icpc_api-0.1.0.dist-info/WHEEL +5 -0
  47. icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
  48. icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
  50. icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Unofficial client for the icpc.global API.
2
+
3
+ Read a whole contest in one call::
4
+
5
+ from icpc import Icpc
6
+
7
+ with Icpc.from_store() as icpc:
8
+ teams = icpc.load_contest(1234)
9
+ print(len(teams.teams), "teams")
10
+ """
11
+
12
+ from icpc.config import Settings
13
+ from icpc.errors import ApiError, AuthError, IcpcError, SearchError
14
+ from icpc.facade.client import AsyncIcpc, Icpc, Include
15
+ from icpc.facade.domain import ContestView, Member, Team
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "ApiError",
21
+ "AsyncIcpc",
22
+ "AuthError",
23
+ "ContestView",
24
+ "Icpc",
25
+ "IcpcError",
26
+ "Include",
27
+ "Member",
28
+ "SearchError",
29
+ "Settings",
30
+ "Team",
31
+ "__version__",
32
+ ]
icpc/api/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Low-level endpoints.
2
+
3
+ Each function is pure: it builds an :class:`~icpc.transport.operation.Operation`
4
+ and performs no I/O. Send one with ``client.send(...)``::
5
+
6
+ from icpc import Icpc
7
+ from icpc.api import team
8
+
9
+ with Icpc.from_store() as icpc:
10
+ roster = icpc.send(team.members(1234567))
11
+
12
+ Not all endpoints are wrapped here. Please fill an issue for missing ones or use
13
+ `icpc raw`.
14
+ """
15
+
16
+ from icpc.api import common, contest, person, public, staff, team
17
+
18
+ __all__ = ["common", "contest", "person", "public", "staff", "team"]
icpc/api/common.py ADDED
@@ -0,0 +1,97 @@
1
+ """``/common``, ``/icpcprofile`` and the ``/aspectfaces`` schema registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from icpc.models.base import Row
6
+ from icpc.models.entities import Globals, InstitutionSuggestion
7
+ from icpc.transport.operation import Operation, Request, list_op, model_op, scalar_op
8
+
9
+ __all__ = [
10
+ "AspectFacesField",
11
+ "AspectFacesSchema",
12
+ "globals_",
13
+ "institution_suggest",
14
+ "schema",
15
+ "wf_year",
16
+ ]
17
+
18
+
19
+ def globals_() -> Operation[Globals]:
20
+ """Site-wide settings: the current World Finals and regionals years."""
21
+ return model_op(Request("GET", "/common/globals/all"), Globals)
22
+
23
+
24
+ def wf_year() -> Operation[int]:
25
+ """The current World Finals year."""
26
+ return scalar_op(Request("GET", "/common/globals/WFYear"), int)
27
+
28
+
29
+ def suggested_institution(institution_id: int) -> Operation[dict[str, object]]:
30
+ """A suggested-institution record."""
31
+ return model_op(
32
+ Request("GET", f"/common/suggestedinstitution/{institution_id}"), dict[str, object]
33
+ )
34
+
35
+
36
+ class AspectFacesField(Row):
37
+ """One field of a server-side form definition."""
38
+
39
+ name: str | None = None
40
+ tag: str | None = None
41
+ label: str | None = None
42
+ label_key: str | None = None
43
+ placeholder: str | None = None
44
+ order: int | None = None
45
+ #: Allowed values, when the field is a choice — the closest thing to an enum
46
+ #: definition this API publishes.
47
+ options: list[object] | None = None
48
+ constraints: object | None = None
49
+ tooltip: str | None = None
50
+
51
+
52
+ class AspectFacesSchema(Row):
53
+ """``GET /aspectfaces/<java.class.Name>`` — a form definition."""
54
+
55
+ name: str | None = None
56
+ fields: list[AspectFacesField] | None = None
57
+ obj: object | None = None
58
+
59
+
60
+ def schema(java_class: str, *associations: str) -> Operation[AspectFacesSchema]:
61
+ """Fetch a server-side form definition.
62
+
63
+ This is the only schema the API exposes. It is the authoritative source for
64
+ enum option lists and required-field constraints, and therefore the right place
65
+ to look before constructing a write payload::
66
+
67
+ schema("global.icpc.base.model.team.businessobjects.Team", "teamInfo")
68
+ """
69
+ path = f"/aspectfaces/{java_class}"
70
+ if associations:
71
+ path += "->" + ",".join(associations)
72
+ return model_op(Request("GET", path), AspectFacesSchema)
73
+
74
+
75
+ def countries() -> Operation[list[dict[str, object]]]:
76
+ """The country list used by the registration forms."""
77
+ return list_op(Request("GET", "/common/country/all"), dict[str, object])
78
+
79
+
80
+ def institution_suggest(
81
+ name: str, *, page: int = 1, size: int = 10
82
+ ) -> Operation[list[InstitutionSuggestion]]:
83
+ """Look an institution up by name, as the UI's picker does.
84
+
85
+ The ``id`` it returns is the ``institutionUnitId`` that
86
+ :func:`icpc.api.team.register` expects. Take care: it is a different number
87
+ from both the ``instId`` and the ``instUnitId`` columns of the institution
88
+ search grid, which are ids in other tables entirely.
89
+ """
90
+ return list_op(
91
+ Request(
92
+ "GET",
93
+ "/common/institutionunit/suggest",
94
+ params={"name": name, "page": page, "size": size},
95
+ ),
96
+ InstitutionSuggestion,
97
+ )
icpc/api/contest.py ADDED
@@ -0,0 +1,253 @@
1
+ """``/contest`` endpoints (the authenticated ones)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from icpc.models.common import NamedRef
6
+ from icpc.models.entities import (
7
+ Breadcrumb,
8
+ Contest,
9
+ ContestManager,
10
+ ContestSettings,
11
+ ContestStats,
12
+ RegistrationInfo,
13
+ SiteRow,
14
+ SiteTreeNode,
15
+ )
16
+ from icpc.transport.operation import (
17
+ Operation,
18
+ Request,
19
+ list_op,
20
+ model_op,
21
+ none_op,
22
+ scalar_op,
23
+ )
24
+
25
+ __all__ = [
26
+ "add_manager",
27
+ "breadcrumbs",
28
+ "create_site",
29
+ "create_subcontest",
30
+ "delete",
31
+ "delete_site",
32
+ "get",
33
+ "managers",
34
+ "next_contest",
35
+ "previous_contest",
36
+ "registration_info",
37
+ "settings",
38
+ "site",
39
+ "site_settings",
40
+ "site_table",
41
+ "site_tree",
42
+ "sites",
43
+ "stats",
44
+ "update",
45
+ "update_registration_info",
46
+ "update_settings",
47
+ "update_site_settings",
48
+ ]
49
+
50
+
51
+ def get(contest_id: int) -> Operation[Contest]:
52
+ """A contest and its settings."""
53
+ return model_op(Request("GET", f"/contest/{contest_id}"), Contest)
54
+
55
+
56
+ def settings(contest_id: int) -> Operation[ContestSettings]:
57
+ """Just the settings block."""
58
+ return model_op(Request("GET", f"/contest/settings/contest/{contest_id}"), ContestSettings)
59
+
60
+
61
+ def sites(contest_id: int) -> Operation[list[NamedRef]]:
62
+ """The contest's sites, id and name only."""
63
+ return list_op(Request("GET", f"/contest/{contest_id}/sites"), NamedRef)
64
+
65
+
66
+ def site_table(contest_id: int) -> Operation[list[SiteRow]]:
67
+ """Sites with capacity and registration flags — the site administration grid."""
68
+ return list_op(Request("GET", f"/contest/site/contest/{contest_id}/table"), SiteRow)
69
+
70
+
71
+ def site_tree(contest_id: int) -> Operation[list[SiteTreeNode]]:
72
+ """The subtree of contests below this one."""
73
+ return list_op(Request("GET", f"/contest/site/tree/{contest_id}"), SiteTreeNode)
74
+
75
+
76
+ def root_tree(*, eager: bool = False) -> Operation[list[SiteTreeNode]]:
77
+ """The top of the contest tree. ``eager`` expands every descendant in one call."""
78
+ path = "/contest/site/tree/root/eager" if eager else "/contest/site/tree/root"
79
+ return list_op(Request("GET", path), SiteTreeNode)
80
+
81
+
82
+ def breadcrumbs(contest_id: int) -> Operation[list[Breadcrumb]]:
83
+ """Where this contest sits in the hierarchy, upwards."""
84
+ return list_op(Request("GET", f"/contest/{contest_id}/breadcrumbs"), Breadcrumb)
85
+
86
+
87
+ def stats(contest_id: int) -> Operation[ContestStats]:
88
+ """Counts of sites, managers, and pending versus accepted teams."""
89
+ return model_op(Request("GET", f"/contest/info/contest/{contest_id}/stats"), ContestStats)
90
+
91
+
92
+ def registration_info(contest_id: int) -> Operation[RegistrationInfo]:
93
+ """Registration windows and which sections registrants must fill in."""
94
+ return model_op(
95
+ Request("GET", f"/contest/registrationinfo/contest/{contest_id}"), RegistrationInfo
96
+ )
97
+
98
+
99
+ def managers(contest_id: int) -> Operation[list[ContestManager]]:
100
+ """Who can administer this contest, and with which permissions."""
101
+ return list_op(Request("GET", f"/contest/access/contest/{contest_id}/managers"), ContestManager)
102
+
103
+
104
+ def has_access(contest_id: int) -> Operation[bool]:
105
+ """Whether the current account may administer this contest."""
106
+ return scalar_op(Request("GET", f"/contest/access/contest/{contest_id}"), bool)
107
+
108
+
109
+ def next_contest(contest_id: int) -> Operation[int]:
110
+ """Id of the next contest in the same series."""
111
+ return scalar_op(Request("GET", f"/contest/info/contest/{contest_id}/next"), int)
112
+
113
+
114
+ def previous_contest(contest_id: int) -> Operation[int]:
115
+ """Id of the previous contest in the same series."""
116
+ return scalar_op(Request("GET", f"/contest/info/contest/{contest_id}/previous"), int)
117
+
118
+
119
+ # ------------------------------------------------------------------ writes --
120
+
121
+
122
+ def create_subcontest(parent_id: int, contest: dict[str, object]) -> Operation[Contest]:
123
+ """Create a contest beneath ``parent_id``.
124
+
125
+ There is no endpoint for creating a *top-level* contest; every contest is
126
+ either a child of another or the product of a rollover.
127
+
128
+ ``contest`` is a Contest object. ``name``, ``shortName`` and ``email`` are
129
+ required; ``abbreviation`` must match ``^[a-zA-Z-]*$`` and be 3 to 42 characters.
130
+ ``GET /aspectfaces/global.icpc.base.model.contest.businessobjects.Contest``
131
+ is the authoritative field list — ``icpc schema`` prints it.
132
+
133
+ The child inherits ``year`` and ``icpcYear`` from its parent, and is created
134
+ with an "Administrative Site" already attached.
135
+ """
136
+ return model_op(
137
+ Request("POST", f"/contest/{parent_id}/subcontest/create", json=contest, idempotent=False),
138
+ Contest,
139
+ )
140
+
141
+
142
+ def delete(contest_id: int) -> Operation[None]:
143
+ """Delete a contest. Used by the UI for subcontests and camps."""
144
+ return none_op(Request("DELETE", f"/contest/{contest_id}", idempotent=False))
145
+
146
+
147
+ def create_site(contest_id: int, site: dict[str, object]) -> Operation[dict[str, object]]:
148
+ """Add a site to a contest. ``name`` (3 to 128 characters) and ``email`` are required.
149
+
150
+ New sites start closed: ``allowRegistration`` is false, so
151
+ :func:`icpc.api.team.site_registrable` reports false until it is opened.
152
+ """
153
+ return Operation(
154
+ Request("POST", f"/contest/site/create/{contest_id}", json=site, idempotent=False),
155
+ lambda r: dict(r.json()) if r.content else {},
156
+ )
157
+
158
+
159
+ def delete_site(site_id: int) -> Operation[None]:
160
+ """Remove a site."""
161
+ return none_op(Request("DELETE", f"/contest/site/{site_id}", idempotent=False))
162
+
163
+
164
+ def add_manager(contest_id: int, person_id: int, *, recursive: bool = False) -> Operation[str]:
165
+ """Grant a person administrative access to a contest.
166
+
167
+ ``recursive`` extends the grant to every contest beneath this one. Requires
168
+ the ``contestGrantPermissions`` right on the contest.
169
+ """
170
+ return Operation(
171
+ Request(
172
+ "POST",
173
+ f"/contest/access/contest/{contest_id}/manager",
174
+ json={"recursive": recursive, "person": {"id": person_id}},
175
+ idempotent=False,
176
+ ),
177
+ lambda r: r.text,
178
+ )
179
+
180
+
181
+ def update(contest_id: int, contest: dict[str, object]) -> Operation[Contest]:
182
+ """Overwrite a contest's own fields — name, dates, hosts, email.
183
+
184
+ A full-object replace: read with :func:`get`, change what you want, send it
185
+ all back. The web UI keeps ``abbreviation``, ``archivalDate`` and
186
+ ``lastRevalidationAt`` read-only even though the endpoint accepts them.
187
+ """
188
+ return model_op(
189
+ Request("POST", f"/contest/{contest_id}", json=contest, idempotent=False), Contest
190
+ )
191
+
192
+
193
+ def update_settings(contest_id: int, settings: dict[str, object]) -> Operation[ContestSettings]:
194
+ """Overwrite the contest settings block — certification, public pages, type."""
195
+ return model_op(
196
+ Request("POST", f"/contest/settings/contest/{contest_id}", json=settings, idempotent=False),
197
+ ContestSettings,
198
+ )
199
+
200
+
201
+ def update_registration_info(
202
+ contest_id: int, info: dict[str, object]
203
+ ) -> Operation[RegistrationInfo]:
204
+ """Overwrite the registration rules — team sizes, windows, what registrants must give.
205
+
206
+ ``allowStudentCoach`` lives here, and must be true before a team can be
207
+ registered with a contestant coach.
208
+ """
209
+ return model_op(
210
+ Request(
211
+ "POST",
212
+ f"/contest/registrationinfo/contest/{contest_id}",
213
+ json=info,
214
+ idempotent=False,
215
+ ),
216
+ RegistrationInfo,
217
+ )
218
+
219
+
220
+ def site_settings(site_id: int) -> Operation[dict[str, object]]:
221
+ """The settings of one site, as embedded in :func:`site`."""
222
+ return Operation(
223
+ Request("GET", f"/contest/site/{site_id}"),
224
+ lambda r: dict(r.json().get("siteSettings") or {}),
225
+ )
226
+
227
+
228
+ def site(site_id: int) -> Operation[dict[str, object]]:
229
+ """One site, with its settings nested under ``siteSettings``."""
230
+ return Operation(Request("GET", f"/contest/site/{site_id}"), lambda r: dict(r.json()))
231
+
232
+
233
+ def update_site_settings(
234
+ contest_id: int, settings: dict[str, object]
235
+ ) -> Operation[dict[str, object]]:
236
+ """Overwrite a site's settings — capacity, and whether it is open.
237
+
238
+ Note the path is keyed by *contest*, while the settings object identifies the
239
+ site; read one with :func:`site_settings`.
240
+
241
+ ``allowRegistration`` and ``allowTeamChanges`` are worth knowing about: while
242
+ ``allowTeamChanges`` is false, adding or removing a team member answers
243
+ **500**, not a clean refusal.
244
+ """
245
+ return Operation(
246
+ Request(
247
+ "POST",
248
+ f"/contest/site/sitesettings/contest/{contest_id}",
249
+ json=settings,
250
+ idempotent=False,
251
+ ),
252
+ lambda r: dict(r.json()) if r.content else {},
253
+ )
icpc/api/person.py ADDED
@@ -0,0 +1,139 @@
1
+ """``/person`` endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from icpc.models.entities import (
8
+ ContactInfo,
9
+ ContestReference,
10
+ Degree,
11
+ Person,
12
+ PersonBasic,
13
+ PersonInfo,
14
+ PersonName,
15
+ PersonSuggestion,
16
+ RegistrationStatus,
17
+ )
18
+ from icpc.transport.operation import Operation, Request, list_op, model_op, scalar_op
19
+
20
+ __all__ = [
21
+ "ReferenceRole",
22
+ "available",
23
+ "contact_info",
24
+ "degree",
25
+ "get",
26
+ "info",
27
+ "name",
28
+ "references",
29
+ "registration_status",
30
+ "suggest",
31
+ "whoami",
32
+ ]
33
+
34
+ #: The UI's person picker waits for this many characters before querying.
35
+ SUGGEST_MIN_LENGTH = 3
36
+
37
+
38
+ def whoami() -> Operation[PersonBasic]:
39
+ """The account behind the current token. The cheapest way to check auth works."""
40
+ return model_op(Request("GET", "/person/info/basic"), PersonBasic)
41
+
42
+
43
+ def get(person_id: int) -> Operation[Person]:
44
+ """A full person record, including the nested personal information."""
45
+ return model_op(Request("GET", f"/person/{person_id}"), Person)
46
+
47
+
48
+ def info(person_id: int) -> Operation[PersonInfo]:
49
+ """The latest personal-information snapshot for a person."""
50
+ return model_op(Request("GET", f"/person/info/person/{person_id}/latest"), PersonInfo)
51
+
52
+
53
+ def name(person_id: int) -> Operation[PersonName]:
54
+ """Just the names — cheaper than :func:`get` when resolving an id."""
55
+ return model_op(Request("GET", f"/person/name/{person_id}"), PersonName)
56
+
57
+
58
+ def contact_info(person_id: int) -> Operation[ContactInfo]:
59
+ """Phone, emergency contact and shipping address."""
60
+ return model_op(Request("GET", f"/person/contactinfo/person/{person_id}"), ContactInfo)
61
+
62
+
63
+ def degree(person_id: int) -> Operation[Degree]:
64
+ """Area of study, degree pursued, and graduation dates."""
65
+ return model_op(Request("GET", f"/person/degree/person/{person_id}"), Degree)
66
+
67
+
68
+ def registration_status(person_id: int) -> Operation[RegistrationStatus]:
69
+ """Whether this person's registration is complete, and what the UI shows them."""
70
+ return model_op(
71
+ Request("GET", f"/person/registration/registrationStatus/{person_id}"),
72
+ RegistrationStatus,
73
+ )
74
+
75
+
76
+ def available(username: str) -> Operation[bool]:
77
+ """Whether a username is free."""
78
+ return scalar_op(Request("GET", f"/person/available/{username}"), bool)
79
+
80
+
81
+ def is_owner(person_id: int) -> Operation[bool]:
82
+ """Whether the current account owns this person record."""
83
+ return scalar_op(Request("GET", f"/person/isowner/{person_id}"), bool)
84
+
85
+
86
+ def suggest(name: str, *, page: int = 1, size: int = 10) -> Operation[list[PersonSuggestion]]:
87
+ """Look a person up by name or email, as the UI's picker does.
88
+
89
+ This is how you turn "Nikita Sychev" or an email address into the person id
90
+ that team registration and staff creation need. The UI waits for three
91
+ characters before querying; shorter terms are accepted but match very widely.
92
+
93
+ ``page`` is 1-based, as everywhere else in this API.
94
+ """
95
+ return list_op(
96
+ Request("GET", "/person/suggest", params={"name": name, "page": page, "size": size}),
97
+ PersonSuggestion,
98
+ )
99
+
100
+
101
+ class ReferenceRole(StrEnum):
102
+ """Roles a person can hold in a contest, as ``/person/references`` spells them."""
103
+
104
+ #: Administrative access — what the cabinet's front page lists.
105
+ CONTEST_MANAGER = "contestmanager"
106
+ SITE_MANAGER = "sitemanager"
107
+ STAFF_MEMBER = "staffmember"
108
+ #: Fills in the team and site fields as well.
109
+ TEAM_MEMBER = "teammember"
110
+ SPONSOR = "sponsor"
111
+ MASTER = "master"
112
+ SLAVE = "slave"
113
+
114
+
115
+ def references(
116
+ person_id: int,
117
+ icpc_year: int,
118
+ role: ReferenceRole | str = ReferenceRole.CONTEST_MANAGER,
119
+ *,
120
+ page: int = 1,
121
+ size: int = 200,
122
+ ) -> Operation[list[ContestReference]]:
123
+ """Contests a person is attached to in ``role``, for one ICPC season.
124
+
125
+ ``icpc_year`` is the **ICPC year**, not the calendar year: NERC-2026 runs in
126
+ calendar 2026 but has ``icpcYear`` 2027, so it is listed under 2027. It is
127
+ the same number the cabinet's year picker shows.
128
+
129
+ ``contestmanager`` is the administrative access the front page lists.
130
+ ``teammember`` additionally fills in the team and site fields.
131
+ """
132
+ return list_op(
133
+ Request(
134
+ "GET",
135
+ f"/person/references/{person_id}/{icpc_year}/{role}/search",
136
+ params={"q": "proj:;", "page": page, "size": size},
137
+ ),
138
+ ContestReference,
139
+ )
icpc/api/public.py ADDED
@@ -0,0 +1,65 @@
1
+ """``/contest/public`` — the endpoints that need no token.
2
+
3
+ Their requests set ``auth=False``, so a client built with no credentials at all can
4
+ still reach them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from icpc.models.entities import ContestUnder, PublicContest, RegionalRef, StandingRow
10
+ from icpc.search.dsl import Q
11
+ from icpc.transport.operation import Operation, Request, list_op, model_op
12
+
13
+ __all__ = ["contest", "contests_under", "regionals", "standings"]
14
+
15
+
16
+ def regionals(year: int) -> Operation[list[RegionalRef]]:
17
+ """The regional contests of a season."""
18
+ return list_op(Request("GET", f"/contest/public/regionals/{year}", auth=False), RegionalRef)
19
+
20
+
21
+ def contests_under(contest_id: int) -> Operation[list[ContestUnder]]:
22
+ """Sub-contests of a contest, with registration counts."""
23
+ return list_op(
24
+ Request("GET", f"/contest/public/contests-under/{contest_id}", auth=False), ContestUnder
25
+ )
26
+
27
+
28
+ def contest(abbreviation: str) -> Operation[PublicContest]:
29
+ """A contest by *abbreviation*, not id.
30
+
31
+ The abbreviation is sometimes year-suffixed (``NERC-2026``); a plain one that
32
+ 404s is usually worth retrying with the season appended.
33
+ """
34
+ return model_op(Request("GET", f"/contest/public/{abbreviation}", auth=False), PublicContest)
35
+
36
+
37
+ def standings(contest_id: int, *, page: int = 1, size: int = 1000) -> Operation[list[StandingRow]]:
38
+ """Published standings for a contest.
39
+
40
+ This is a search endpoint like the authenticated ones, but the empty projection
41
+ is what the public pages send, and it returns the default columns.
42
+ """
43
+ return list_op(
44
+ Request(
45
+ "GET",
46
+ f"/contest/public/search/contest/{contest_id}",
47
+ params={"q": Q().render(), "page": page, "size": size},
48
+ auth=False,
49
+ ),
50
+ StandingRow,
51
+ )
52
+
53
+
54
+ def schedules(contest_id: int) -> Operation[list[dict[str, object]]]:
55
+ """The contest's published schedule entries."""
56
+ return list_op(
57
+ Request("GET", f"/contest/public/{contest_id}/schedules", auth=False), dict[str, object]
58
+ )
59
+
60
+
61
+ def regional_results(year: int) -> Operation[list[dict[str, object]]]:
62
+ """The regional results tree for a season, contests nested under their parents."""
63
+ return list_op(
64
+ Request("GET", f"/contest/public/regionalresults/{year}", auth=False), dict[str, object]
65
+ )
icpc/api/staff.py ADDED
@@ -0,0 +1,83 @@
1
+ """``/contest/staffmember`` — contest staff.
2
+
3
+ A *staff member* is a person attached to a site with a badge and certificate
4
+ role. That is distinct from a *contest manager*, who has administrative
5
+ permissions on the contest; managers live in :mod:`icpc.api.contest`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from icpc.models._generated import StaffMemberRow
11
+ from icpc.transport.operation import Operation, Request, model_op, none_op
12
+
13
+ __all__ = ["add", "delete", "get", "update"]
14
+
15
+
16
+ def get(staff_member_id: int) -> Operation[StaffMemberRow]:
17
+ """One staff member."""
18
+ return model_op(Request("GET", f"/contest/staffmember/{staff_member_id}"), StaffMemberRow)
19
+
20
+
21
+ def add(
22
+ site_id: int,
23
+ person_id: int,
24
+ *,
25
+ badge_role: str,
26
+ certificate_role: str,
27
+ show_in_public_pages: bool = False,
28
+ ) -> Operation[dict[str, object]]:
29
+ """Attach a person to a site as staff.
30
+
31
+ Both roles are free text — they are printed on the badge and the certificate,
32
+ so they are whatever the contest wants to call the job. The web form refuses
33
+ to submit without both, and this mirrors that by requiring them.
34
+
35
+ Resolve ``person_id`` with :func:`icpc.api.person.suggest`.
36
+ """
37
+ return Operation(
38
+ Request(
39
+ "POST",
40
+ f"/contest/staffmember/site/{site_id}",
41
+ json={
42
+ "smId": None,
43
+ "personId": person_id,
44
+ "badgeRole": badge_role,
45
+ "certificateRole": certificate_role,
46
+ "showInPublicPages": show_in_public_pages,
47
+ },
48
+ idempotent=False,
49
+ ),
50
+ lambda r: dict(r.json()) if r.content else {},
51
+ )
52
+
53
+
54
+ def update(
55
+ site_id: int,
56
+ staff_member_id: int,
57
+ person_id: int,
58
+ *,
59
+ badge_role: str,
60
+ certificate_role: str,
61
+ show_in_public_pages: bool = False,
62
+ ) -> Operation[dict[str, object]]:
63
+ """Change an existing staff member. Same body as :func:`add`, plus ``smId``."""
64
+ return Operation(
65
+ Request(
66
+ "PUT",
67
+ f"/contest/staffmember/site/{site_id}",
68
+ json={
69
+ "smId": staff_member_id,
70
+ "personId": person_id,
71
+ "badgeRole": badge_role,
72
+ "certificateRole": certificate_role,
73
+ "showInPublicPages": show_in_public_pages,
74
+ },
75
+ idempotent=False,
76
+ ),
77
+ lambda r: dict(r.json()) if r.content else {},
78
+ )
79
+
80
+
81
+ def delete(staff_member_id: int) -> Operation[None]:
82
+ """Remove a staff member."""
83
+ return none_op(Request("DELETE", f"/contest/staffmember/{staff_member_id}", idempotent=False))