icpc-api 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {icpc_api-0.1.0/icpc_api.egg-info → icpc_api-0.2.0}/PKG-INFO +1 -1
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/__init__.py +1 -1
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/__init__.py +2 -2
- icpc_api-0.2.0/icpc/api/survey.py +31 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/columns.py +3 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/main.py +62 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/client.py +38 -1
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/domain.py +12 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/__init__.py +8 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/_generated.py +3 -3
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/blobs.py +1 -2
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/enums.py +34 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/mixins.py +1 -5
- icpc_api-0.2.0/icpc/models/surveys.py +164 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/__init__.py +3 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/_generated.py +3 -3
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/endpoint.py +7 -0
- icpc_api-0.2.0/icpc/search/surveys.py +76 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0/icpc_api.egg-info}/PKG-INFO +1 -1
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/SOURCES.txt +5 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/pyproject.toml +4 -0
- icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_columns.py +15 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_join.py +41 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_reference.py +2 -2
- icpc_api-0.2.0/tests/unit/test_surveys.py +117 -0
- icpc_api-0.2.0/uv.lock +504 -0
- icpc_api-0.1.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.1.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.1.0/uv.lock +0 -504
- {icpc_api-0.1.0 → icpc_api-0.2.0}/LICENSE +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/MANIFEST.in +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/README.md +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/THIRD-PARTY-LICENSES.md +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/common.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/contest.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/person.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/public.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/staff.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/team.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/cognito.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/flows.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/provider.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/srp.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/store.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/tokens.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/render.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/config.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/errors.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/base.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/common.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/entities.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/py.typed +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/dsl.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/fields.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/_shared.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/async_client.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/operation.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/sync_client.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/dependency_links.txt +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/entry_points.txt +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/requires.txt +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/top_level.txt +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/setup.cfg +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__init__.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_auth.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_dsl.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_models.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_paging.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_srp.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_transport.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_transport_parity.py +0 -0
- {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_typing.py +0 -0
|
@@ -14,7 +14,7 @@ from icpc.errors import ApiError, AuthError, IcpcError, SearchError
|
|
|
14
14
|
from icpc.facade.client import AsyncIcpc, Icpc, Include
|
|
15
15
|
from icpc.facade.domain import ContestView, Member, Team
|
|
16
16
|
|
|
17
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
20
|
"ApiError",
|
|
@@ -13,6 +13,6 @@ Not all endpoints are wrapped here. Please fill an issue for missing ones or use
|
|
|
13
13
|
`icpc raw`.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from icpc.api import common, contest, person, public, staff, team
|
|
16
|
+
from icpc.api import common, contest, person, public, staff, survey, team
|
|
17
17
|
|
|
18
|
-
__all__ = ["common", "contest", "person", "public", "staff", "team"]
|
|
18
|
+
__all__ = ["common", "contest", "person", "public", "staff", "survey", "team"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""``/contest/survey`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from icpc.models.surveys import Survey, SurveyField
|
|
6
|
+
from icpc.transport.operation import Operation, Request, list_op, model_op
|
|
7
|
+
|
|
8
|
+
__all__ = ["fields", "for_contest", "get"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def for_contest(contest_id: int) -> Operation[list[Survey]]:
|
|
12
|
+
"""Every survey of a contest."""
|
|
13
|
+
return list_op(Request("GET", f"/contest/survey/{contest_id}/table"), Survey)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get(survey_id: int) -> Operation[Survey]:
|
|
17
|
+
"""One survey, by its own id rather than its contest's.
|
|
18
|
+
|
|
19
|
+
Note that this endpoint and :func:`for_contest` return different subset
|
|
20
|
+
of fields about the model.
|
|
21
|
+
"""
|
|
22
|
+
return model_op(Request("GET", f"/contest/survey/{survey_id}"), Survey)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fields(survey_id: int) -> Operation[list[SurveyField]]:
|
|
26
|
+
"""A survey's fields, in ``fieldOrder``.
|
|
27
|
+
|
|
28
|
+
``DESCRIPTION`` entries are not questions but just text labels; see
|
|
29
|
+
:attr:`~icpc.models.surveys.SurveyField.is_question`.
|
|
30
|
+
"""
|
|
31
|
+
return list_op(Request("GET", f"/contest/survey/field/{survey_id}/table"), SurveyField)
|
|
@@ -87,6 +87,7 @@ def _member(member: Member) -> dict[str, Any]:
|
|
|
87
87
|
row["completeRegistration"] = member.registration_complete
|
|
88
88
|
row["participant"] = _dump(member.participant)
|
|
89
89
|
row["extras"] = member.extras
|
|
90
|
+
row["survey"] = member.survey
|
|
90
91
|
return row
|
|
91
92
|
|
|
92
93
|
|
|
@@ -239,6 +240,7 @@ def _member_shape() -> Fields:
|
|
|
239
240
|
**_from_model(TeamMemberRow),
|
|
240
241
|
"participant": _from_model(ContestParticipantRow),
|
|
241
242
|
"extras": Open,
|
|
243
|
+
"survey": Open,
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
|
|
@@ -321,6 +323,7 @@ _NEEDS = {
|
|
|
321
323
|
"participant": "participants",
|
|
322
324
|
"contest": "metadata",
|
|
323
325
|
"siteId": "metadata",
|
|
326
|
+
"survey": "surveys",
|
|
324
327
|
}
|
|
325
328
|
|
|
326
329
|
|
|
@@ -21,6 +21,7 @@ from icpc.api import contest as contest_api
|
|
|
21
21
|
from icpc.api import person as person_api
|
|
22
22
|
from icpc.api import public as public_api
|
|
23
23
|
from icpc.api import staff as staff_api
|
|
24
|
+
from icpc.api import survey as survey_api
|
|
24
25
|
from icpc.api import team as team_api
|
|
25
26
|
from icpc.api.person import ReferenceRole
|
|
26
27
|
from icpc.api.team import NewTeam, NewTeamMember
|
|
@@ -45,6 +46,7 @@ from icpc.models.enums import ExportType, MemberRole, TeamStatus
|
|
|
45
46
|
from icpc.search import _generated as endpoints
|
|
46
47
|
from icpc.search.dsl import Filter, SortKey
|
|
47
48
|
from icpc.search.endpoint import SearchEndpoint
|
|
49
|
+
from icpc.search.surveys import survey_responses
|
|
48
50
|
from icpc.transport.operation import Request, json_op
|
|
49
51
|
|
|
50
52
|
app = typer.Typer(no_args_is_help=True, help=__doc__, add_completion=True)
|
|
@@ -54,12 +56,14 @@ team_app = typer.Typer(no_args_is_help=True, help="Read and modify teams.")
|
|
|
54
56
|
public_app = typer.Typer(no_args_is_help=True, help="Public endpoints; no login needed.")
|
|
55
57
|
person_app = typer.Typer(no_args_is_help=True, help="Look people up.")
|
|
56
58
|
staff_app = typer.Typer(no_args_is_help=True, help="Contest staff.")
|
|
59
|
+
survey_app = typer.Typer(no_args_is_help=True, help="Contest surveys and their answers.")
|
|
57
60
|
app.add_typer(auth_app, name="auth")
|
|
58
61
|
app.add_typer(contest_app, name="contest")
|
|
59
62
|
app.add_typer(team_app, name="team")
|
|
60
63
|
app.add_typer(public_app, name="public")
|
|
61
64
|
app.add_typer(person_app, name="person")
|
|
62
65
|
app.add_typer(staff_app, name="staff")
|
|
66
|
+
app.add_typer(survey_app, name="survey")
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
@dataclass
|
|
@@ -404,6 +408,64 @@ def staff_remove(ctx: typer.Context, staff_member_id: int) -> None:
|
|
|
404
408
|
note(f"staff member {staff_member_id} removed")
|
|
405
409
|
|
|
406
410
|
|
|
411
|
+
# ------------------------------------------------------------------ survey --
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@survey_app.command("list")
|
|
415
|
+
def survey_list(ctx: typer.Context, contest_id: int) -> None:
|
|
416
|
+
"""Surveys of a contest.
|
|
417
|
+
|
|
418
|
+
Most contests have none. `visibility` says who the survey was put to, but
|
|
419
|
+
the answers come back per person either way.
|
|
420
|
+
"""
|
|
421
|
+
with _client(ctx) as icpc:
|
|
422
|
+
rows = icpc.send(survey_api.for_contest(contest_id))
|
|
423
|
+
if not rows:
|
|
424
|
+
warn(f"contest {contest_id} has no surveys")
|
|
425
|
+
render(
|
|
426
|
+
rows,
|
|
427
|
+
_ctx(ctx).output,
|
|
428
|
+
columns=["id", "name", "visibility", "responses", "acceptsResponses", "surveyEndDate"],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@survey_app.command("fields")
|
|
433
|
+
def survey_fields(ctx: typer.Context, survey_id: int) -> None:
|
|
434
|
+
"""Questions of a survey, in display order.
|
|
435
|
+
|
|
436
|
+
This is the lookup that decodes an answer: responses and `contest load`
|
|
437
|
+
columns are keyed by the `id` shown here. A `DESCRIPTION` row is static text
|
|
438
|
+
rather than a question, so it never carries an answer.
|
|
439
|
+
"""
|
|
440
|
+
with _client(ctx) as icpc:
|
|
441
|
+
rows = icpc.send(survey_api.fields(survey_id))
|
|
442
|
+
render(rows, _ctx(ctx).output, columns=["id", "type", "name", "hint", "defaultValue"])
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@survey_app.command("responses")
|
|
446
|
+
def survey_responses_cmd(ctx: typer.Context, survey_id: int) -> None:
|
|
447
|
+
"""Answers to a survey, one row per person who replied.
|
|
448
|
+
|
|
449
|
+
Answer columns are named by field id — `icpc survey fields` maps them back to
|
|
450
|
+
the questions. The server ignores `filter:` here, so narrowing is a job for
|
|
451
|
+
the shell.
|
|
452
|
+
|
|
453
|
+
icpc -o csv survey responses 894
|
|
454
|
+
"""
|
|
455
|
+
endpoint = survey_responses(survey_id)
|
|
456
|
+
with _client(ctx) as icpc:
|
|
457
|
+
rows = icpc.all(endpoint)
|
|
458
|
+
if not rows:
|
|
459
|
+
warn(f"survey {survey_id} has no responses")
|
|
460
|
+
columns = list(endpoint.all_fields)
|
|
461
|
+
seen: list[str] = []
|
|
462
|
+
for row in rows:
|
|
463
|
+
for key in row.answers:
|
|
464
|
+
if key not in seen:
|
|
465
|
+
seen.append(key)
|
|
466
|
+
render(rows, _ctx(ctx).output, columns=columns + sorted(seen, key=int))
|
|
467
|
+
|
|
468
|
+
|
|
407
469
|
# ----------------------------------------------------------------- contest --
|
|
408
470
|
|
|
409
471
|
|
|
@@ -17,6 +17,7 @@ from typing import Any, Self
|
|
|
17
17
|
from icpc import errors
|
|
18
18
|
from icpc.api import contest as contest_api
|
|
19
19
|
from icpc.api import person as person_api
|
|
20
|
+
from icpc.api import survey as survey_api
|
|
20
21
|
from icpc.api import team as team_api
|
|
21
22
|
from icpc.api.person import ReferenceRole
|
|
22
23
|
from icpc.auth.flows import AsyncCognitoAuth, CognitoAuth, StaticTokenAuth, SyncStaticTokenAuth
|
|
@@ -25,9 +26,11 @@ from icpc.auth.tokens import TokenSet
|
|
|
25
26
|
from icpc.config import DEFAULT_PAGE_SIZE, Settings
|
|
26
27
|
from icpc.facade.domain import ContestView, join
|
|
27
28
|
from icpc.models.entities import ContestReference, PersonBasic, Team
|
|
29
|
+
from icpc.models.surveys import SurveyResponseRow
|
|
28
30
|
from icpc.search import _generated as endpoints
|
|
29
31
|
from icpc.search.dsl import Q
|
|
30
32
|
from icpc.search.endpoint import SearchEndpoint
|
|
33
|
+
from icpc.search.surveys import survey_responses
|
|
31
34
|
from icpc.transport.async_client import AsyncTransport
|
|
32
35
|
from icpc.transport.operation import Operation
|
|
33
36
|
from icpc.transport.sync_client import Transport
|
|
@@ -80,10 +83,12 @@ class Include(Flag):
|
|
|
80
83
|
INSTITUTIONS = auto()
|
|
81
84
|
PARTICIPANTS = auto()
|
|
82
85
|
METADATA = auto()
|
|
86
|
+
#: Every survey of the contest, and every response to each.
|
|
87
|
+
SURVEYS = auto()
|
|
83
88
|
|
|
84
89
|
#: Teams, rosters, institutions and contest metadata — not the participant table.
|
|
85
90
|
DEFAULT = TEAMS | MEMBERS | INSTITUTIONS | METADATA
|
|
86
|
-
ALL = TEAMS | MEMBERS | INSTITUTIONS | PARTICIPANTS | METADATA
|
|
91
|
+
ALL = TEAMS | MEMBERS | INSTITUTIONS | PARTICIPANTS | METADATA | SURVEYS
|
|
87
92
|
|
|
88
93
|
@classmethod
|
|
89
94
|
def named(cls, names: Iterable[str]) -> Include:
|
|
@@ -308,6 +313,19 @@ class AsyncIcpc:
|
|
|
308
313
|
async def _all_columns[R, F](self, endpoint: SearchEndpoint[R, F]) -> list[R]:
|
|
309
314
|
return await self.all(endpoint, _full(endpoint))
|
|
310
315
|
|
|
316
|
+
async def _survey_rows(self, contest_id: int) -> list[SurveyResponseRow]:
|
|
317
|
+
"""Every response to every survey of a contest, in one flat list.
|
|
318
|
+
|
|
319
|
+
Two stages, because responses are keyed by survey rather than by
|
|
320
|
+
contest: list the surveys, then fetch each one's rows.
|
|
321
|
+
"""
|
|
322
|
+
surveys = await self.send(survey_api.for_contest(contest_id))
|
|
323
|
+
ids = [s.id for s in surveys if s.id is not None]
|
|
324
|
+
if not ids:
|
|
325
|
+
return []
|
|
326
|
+
fetched = await asyncio.gather(*(self.all(survey_responses(i)) for i in ids))
|
|
327
|
+
return [row for rows in fetched for row in rows]
|
|
328
|
+
|
|
311
329
|
# ------------------------------------------------------ joined contest --
|
|
312
330
|
|
|
313
331
|
async def load_contest(
|
|
@@ -345,6 +363,11 @@ class AsyncIcpc:
|
|
|
345
363
|
if Include.PARTICIPANTS in include
|
|
346
364
|
else None
|
|
347
365
|
)
|
|
366
|
+
surveys = (
|
|
367
|
+
group.create_task(self._survey_rows(contest_id))
|
|
368
|
+
if Include.SURVEYS in include
|
|
369
|
+
else None
|
|
370
|
+
)
|
|
348
371
|
|
|
349
372
|
return join(
|
|
350
373
|
teams.result(),
|
|
@@ -353,6 +376,7 @@ class AsyncIcpc:
|
|
|
353
376
|
members=members.result() if members else (),
|
|
354
377
|
institutions=institutions.result() if institutions else (),
|
|
355
378
|
participants=participants.result() if participants else (),
|
|
379
|
+
survey_responses=surveys.result() if surveys else (),
|
|
356
380
|
)
|
|
357
381
|
|
|
358
382
|
# -------------------------------------------------------------- writes --
|
|
@@ -546,6 +570,17 @@ class Icpc:
|
|
|
546
570
|
def _all_columns[R, F](self, endpoint: SearchEndpoint[R, F]) -> list[R]:
|
|
547
571
|
return self.all(endpoint, _full(endpoint))
|
|
548
572
|
|
|
573
|
+
def _survey_rows(self, contest_id: int) -> list[SurveyResponseRow]:
|
|
574
|
+
"""Every response to every survey of a contest — see
|
|
575
|
+
:meth:`AsyncIcpc._survey_rows`, of which this is the sequential twin.
|
|
576
|
+
"""
|
|
577
|
+
surveys = self.send(survey_api.for_contest(contest_id))
|
|
578
|
+
rows: list[SurveyResponseRow] = []
|
|
579
|
+
for one in surveys:
|
|
580
|
+
if one.id is not None:
|
|
581
|
+
rows.extend(self.all(survey_responses(one.id)))
|
|
582
|
+
return rows
|
|
583
|
+
|
|
549
584
|
# ------------------------------------------------------ joined contest --
|
|
550
585
|
|
|
551
586
|
def load_contest(self, contest_id: int, include: Include = Include.DEFAULT) -> ContestView:
|
|
@@ -572,6 +607,7 @@ class Icpc:
|
|
|
572
607
|
if Include.PARTICIPANTS in include
|
|
573
608
|
else []
|
|
574
609
|
)
|
|
610
|
+
surveys = self._survey_rows(contest_id) if Include.SURVEYS in include else []
|
|
575
611
|
return join(
|
|
576
612
|
teams,
|
|
577
613
|
contest=metadata,
|
|
@@ -579,6 +615,7 @@ class Icpc:
|
|
|
579
615
|
members=members,
|
|
580
616
|
institutions=institutions,
|
|
581
617
|
participants=participants,
|
|
618
|
+
survey_responses=surveys,
|
|
582
619
|
)
|
|
583
620
|
|
|
584
621
|
# -------------------------------------------------------------- writes --
|
|
@@ -30,6 +30,7 @@ from icpc.models.blobs import TeamMemberBlob
|
|
|
30
30
|
from icpc.models.common import NamedRef
|
|
31
31
|
from icpc.models.entities import Contest
|
|
32
32
|
from icpc.models.enums import TeamStatus, coach_roles, contestant_roles
|
|
33
|
+
from icpc.models.surveys import SurveyResponseRow, merge_answers
|
|
33
34
|
|
|
34
35
|
__all__ = ["ContestView", "Member", "Team", "join"]
|
|
35
36
|
|
|
@@ -50,6 +51,10 @@ class Member(TeamMemberRow):
|
|
|
50
51
|
registration_complete: bool | None = None
|
|
51
52
|
#: The person's contest-wide record, when the participant table was fetched.
|
|
52
53
|
participant: ContestParticipantRow | None = None
|
|
54
|
+
#: Survey answers for this person, keyed by survey field id, merged across
|
|
55
|
+
#: every survey fetched. Empty unless the surveys were asked for, and empty
|
|
56
|
+
#: for anyone who did not answer.
|
|
57
|
+
survey: dict[str, str] = Field(default_factory=dict)
|
|
53
58
|
|
|
54
59
|
|
|
55
60
|
class Team(TeamRow):
|
|
@@ -137,6 +142,7 @@ def _member_from_row(row: TeamMemberRow) -> Member:
|
|
|
137
142
|
**_columns(row),
|
|
138
143
|
registration_complete=row.complete_registration,
|
|
139
144
|
participant=None,
|
|
145
|
+
survey={},
|
|
140
146
|
)
|
|
141
147
|
|
|
142
148
|
|
|
@@ -147,6 +153,7 @@ def _member_from_blob(blob: TeamMemberBlob) -> Member:
|
|
|
147
153
|
role=blob.role,
|
|
148
154
|
registration_complete=blob.reg_complete,
|
|
149
155
|
participant=None,
|
|
156
|
+
survey={},
|
|
150
157
|
)
|
|
151
158
|
|
|
152
159
|
|
|
@@ -158,14 +165,18 @@ def join(
|
|
|
158
165
|
members: Iterable[TeamMemberRow] = (),
|
|
159
166
|
institutions: Iterable[InstitutionRow] = (),
|
|
160
167
|
participants: Iterable[ContestParticipantRow] = (),
|
|
168
|
+
survey_responses: Iterable[SurveyResponseRow] = (),
|
|
161
169
|
) -> ContestView:
|
|
162
170
|
"""Build a :class:`ContestView` from raw search rows.
|
|
163
171
|
|
|
164
172
|
When ``members`` is empty the roster falls back to each team row's embedded
|
|
165
173
|
``teamMembers`` blob, which is thinner but costs no extra request.
|
|
174
|
+
|
|
175
|
+
``survey_responses`` may hold the rows of several surveys at once.
|
|
166
176
|
"""
|
|
167
177
|
by_inst = {row.inst_id: row for row in institutions if row.inst_id is not None}
|
|
168
178
|
by_person = {row.person_id: row for row in participants if row.person_id is not None}
|
|
179
|
+
answers = merge_answers(survey_responses)
|
|
169
180
|
|
|
170
181
|
rosters: dict[int, list[Member]] = {}
|
|
171
182
|
for row in members:
|
|
@@ -181,6 +192,7 @@ def join(
|
|
|
181
192
|
for member in roster:
|
|
182
193
|
if member.person_id is not None:
|
|
183
194
|
member.participant = by_person.get(member.person_id)
|
|
195
|
+
member.survey = answers.get(member.person_id, {})
|
|
184
196
|
built.append(
|
|
185
197
|
Team.model_construct(
|
|
186
198
|
**_columns(row),
|
|
@@ -24,10 +24,13 @@ from icpc.models.enums import (
|
|
|
24
24
|
ExportType,
|
|
25
25
|
MemberRole,
|
|
26
26
|
ParticipantRole,
|
|
27
|
+
SurveyFieldType,
|
|
28
|
+
SurveyVisibility,
|
|
27
29
|
TeamStatus,
|
|
28
30
|
coach_roles,
|
|
29
31
|
contestant_roles,
|
|
30
32
|
)
|
|
33
|
+
from icpc.models.surveys import Survey, SurveyField, SurveyResponseRow
|
|
31
34
|
|
|
32
35
|
__all__ = [
|
|
33
36
|
"CertificateRow",
|
|
@@ -45,6 +48,11 @@ __all__ = [
|
|
|
45
48
|
"StaffRow",
|
|
46
49
|
"StaffTshirtRow",
|
|
47
50
|
"StandingsRow",
|
|
51
|
+
"Survey",
|
|
52
|
+
"SurveyField",
|
|
53
|
+
"SurveyFieldType",
|
|
54
|
+
"SurveyResponseRow",
|
|
55
|
+
"SurveyVisibility",
|
|
48
56
|
"TeamMemberBlob",
|
|
49
57
|
"TeamMemberRow",
|
|
50
58
|
"TeamRow",
|
|
@@ -71,7 +71,7 @@ class CertificateRow(HasTeamMembers, Row):
|
|
|
71
71
|
team_name: str | None = _Field(default=None, description="Team Name")
|
|
72
72
|
team_id: int | None = None
|
|
73
73
|
team_members: str | None = None
|
|
74
|
-
lite_team_member_set:
|
|
74
|
+
lite_team_member_set: list | None = None
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
class ContestParticipantRow(Row):
|
|
@@ -408,7 +408,7 @@ class TeamMemberRow(HasExtraFields, Row):
|
|
|
408
408
|
certificate_name: str | None = _Field(default=None, description="Certificate Name")
|
|
409
409
|
team_id: int | None = _Field(default=None, description="Team ID")
|
|
410
410
|
team_name: str | None = _Field(default=None, description="Team Name")
|
|
411
|
-
workstation_id:
|
|
411
|
+
workstation_id: str | None = _Field(default=None, description="Workstation Id")
|
|
412
412
|
team_status: TeamStatus | str | None = _Field(
|
|
413
413
|
default=None, union_mode="left_to_right", description="Team Status"
|
|
414
414
|
)
|
|
@@ -442,7 +442,7 @@ class TeamRow(HasTeamMembers, HasExtraFields, Row):
|
|
|
442
442
|
paid: bool | None = _Field(default=None, description="Paid")
|
|
443
443
|
note: str | None = _Field(default=None, description="Note")
|
|
444
444
|
check_in: str | None = _Field(default=None, description="Check-In")
|
|
445
|
-
workstation_id:
|
|
445
|
+
workstation_id: str | None = _Field(default=None, description="Workstation Id")
|
|
446
446
|
rank: int | None = _Field(default=None, description="Rank")
|
|
447
447
|
certified: bool | None = _Field(default=None, description="Certified")
|
|
448
448
|
eligibility_status: EligibilityStatus | str | None = _Field(
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Two search columns carry JSON encoded *as a string* rather than as nested JSON:
|
|
4
4
|
|
|
5
5
|
* ``teamMembers`` on a team row — the roster, so a team listing needs no second call.
|
|
6
|
-
* ``extraField``
|
|
7
|
-
questions, which differ per contest.
|
|
6
|
+
* ``extraField`` — the answers to a contest's extra registration fields
|
|
8
7
|
|
|
9
8
|
Both are parsed here into real objects, and always tolerantly: a malformed blob
|
|
10
9
|
yields an empty result rather than failing the whole page.
|
|
@@ -19,6 +19,8 @@ __all__ = [
|
|
|
19
19
|
"PublicPagesVisibility",
|
|
20
20
|
"Sex",
|
|
21
21
|
"ShirtSize",
|
|
22
|
+
"SurveyFieldType",
|
|
23
|
+
"SurveyVisibility",
|
|
22
24
|
"TeamStatus",
|
|
23
25
|
"Title",
|
|
24
26
|
"coach_roles",
|
|
@@ -120,6 +122,38 @@ class PublicPagesVisibility(StrEnum):
|
|
|
120
122
|
WITHOUT_PEOPLE = "WITHOUT_PEOPLE"
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
class SurveyVisibility(StrEnum):
|
|
126
|
+
"""Who a survey is shown to.
|
|
127
|
+
|
|
128
|
+
Complete, from ``…contest.businessobjects.survey.Survey``.
|
|
129
|
+
|
|
130
|
+
``TEAMSACCPTED`` is the server-side typo.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
ALL = "ALL"
|
|
134
|
+
STAFF = "STAFF"
|
|
135
|
+
TEAMSACCPTED = "TEAMSACCPTED"
|
|
136
|
+
TEAMS = "TEAMS"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SurveyFieldType(StrEnum):
|
|
140
|
+
"""What one survey field asks for.
|
|
141
|
+
|
|
142
|
+
Complete, from ``…contest.businessobjects.survey.SurveyField``; the six match
|
|
143
|
+
the constants in the frontend's survey renderer.
|
|
144
|
+
|
|
145
|
+
``DESCRIPTION`` is static text rather than a question, and never carries an
|
|
146
|
+
answer.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
DESCRIPTION = "DESCRIPTION"
|
|
150
|
+
SHORT_ANSWER = "SHORT_ANSWER"
|
|
151
|
+
LONG_ANSWER = "LONG_ANSWER"
|
|
152
|
+
DROPDOWN = "DROPDOWN"
|
|
153
|
+
CHECKBOXES = "CHECKBOXES"
|
|
154
|
+
USERSLIST = "USERSLIST"
|
|
155
|
+
|
|
156
|
+
|
|
123
157
|
class Title(StrEnum):
|
|
124
158
|
"""Complete, from ``…person.businessobjects.PersonInfoAbstract``."""
|
|
125
159
|
|
|
@@ -36,9 +36,5 @@ class HasExtraFields:
|
|
|
36
36
|
|
|
37
37
|
@property
|
|
38
38
|
def extras(self) -> dict[str, str]:
|
|
39
|
-
"""Answers to the contest's
|
|
40
|
-
|
|
41
|
-
Keys are the contest's own question labels, so they differ between
|
|
42
|
-
contests and are frequently not in English.
|
|
43
|
-
"""
|
|
39
|
+
"""Answers to the contest's extra registration fields."""
|
|
44
40
|
return parse_extra_fields(self.extra_field)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Survey definition.
|
|
2
|
+
|
|
3
|
+
A survey is a form linked to the contest. *Fields* define the questions.
|
|
4
|
+
Respondents are defined by :class:`~icpc.models.enums.SurveyVisibility`.
|
|
5
|
+
Each eligible person submit their own response.
|
|
6
|
+
|
|
7
|
+
A response row carries the person's identity in named columns and their answers
|
|
8
|
+
keyed by :attr:`SurveyField.id` as a string, so the answers arrive in
|
|
9
|
+
``model_extra`` rather than as declared fields. Read them off
|
|
10
|
+
:attr:`SurveyResponseRow.answers`.
|
|
11
|
+
|
|
12
|
+
Answers are always strings on the wire, whatever the field type: a
|
|
13
|
+
``CHECKBOXES`` multi-selection arrives as one joined string rather than a list.
|
|
14
|
+
Nothing here parses them further, because the separator is not consistent — see
|
|
15
|
+
:attr:`SurveyField.options`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from datetime import date
|
|
23
|
+
|
|
24
|
+
from pydantic import Field
|
|
25
|
+
|
|
26
|
+
from icpc.models.base import Row
|
|
27
|
+
from icpc.models.enums import SurveyFieldType, SurveyVisibility
|
|
28
|
+
|
|
29
|
+
__all__ = ["Survey", "SurveyField", "SurveyResponseRow", "merge_answers"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Survey(Row):
|
|
33
|
+
"""One survey. ``GET /contest/survey/{contest_id}/table`` lists a contest's."""
|
|
34
|
+
|
|
35
|
+
id: int | None = None
|
|
36
|
+
#: Required server-side, 3 to 128 characters.
|
|
37
|
+
name: str | None = None
|
|
38
|
+
#: Up to 65536 characters.
|
|
39
|
+
description: str | None = None
|
|
40
|
+
#: Required server-side.
|
|
41
|
+
visibility: SurveyVisibility | str | None = Field(default=None, union_mode="left_to_right")
|
|
42
|
+
accepts_responses: bool | None = None
|
|
43
|
+
survey_end_date: date | None = None
|
|
44
|
+
#: How many people have answered — a count, not the responses themselves.
|
|
45
|
+
responses: int | None = None
|
|
46
|
+
#: Present on ``GET /contest/survey/{survey_id}``, absent from the list rows.
|
|
47
|
+
contest_id: int | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SurveyField(Row):
|
|
51
|
+
"""One question. ``GET /contest/survey/field/{survey_id}/table``.
|
|
52
|
+
|
|
53
|
+
``next`` and ``prev`` chain the fields in display order, alongside
|
|
54
|
+
:attr:`field_order`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
id: int | None = None
|
|
58
|
+
#: Required server-side, 3 to 255 characters.
|
|
59
|
+
name: str | None = None
|
|
60
|
+
#: Up to 128 characters.
|
|
61
|
+
hint: str | None = None
|
|
62
|
+
#: Overloaded: prose for a ``DESCRIPTION``, the option list for a
|
|
63
|
+
#: ``DROPDOWN`` or ``CHECKBOXES``. Up to 65536 characters. See :attr:`options`.
|
|
64
|
+
default_value: str | None = None
|
|
65
|
+
#: Required server-side.
|
|
66
|
+
type: SurveyFieldType | str | None = Field(default=None, union_mode="left_to_right")
|
|
67
|
+
field_order: int | None = None
|
|
68
|
+
#: The ``name`` — not the id — of the field this one's display depends on.
|
|
69
|
+
dependency_field_name: str | None = None
|
|
70
|
+
#: Comma-separated values of ``dependency_field_name`` that reveal this field.
|
|
71
|
+
#: When the dependency is a ``CHECKBOXES``, any overlap counts, not equality.
|
|
72
|
+
dependency_field_value: str | None = None
|
|
73
|
+
image: str | None = None
|
|
74
|
+
next: int | None = None
|
|
75
|
+
prev: int | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_question(self) -> bool:
|
|
79
|
+
"""Whether this field can hold an answer.
|
|
80
|
+
|
|
81
|
+
``DESCRIPTION`` fields are static text the form displays, so they are
|
|
82
|
+
never answered and are not worth offering as a column.
|
|
83
|
+
"""
|
|
84
|
+
return self.type != SurveyFieldType.DESCRIPTION
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def options(self) -> list[str]:
|
|
88
|
+
"""The choices of a ``DROPDOWN`` or ``CHECKBOXES``, decoded as the UI does.
|
|
89
|
+
|
|
90
|
+
The frontend reads :attr:`default_value` as a JSON array of objects
|
|
91
|
+
carrying a ``name``, and falls back to splitting on commas. That fallback
|
|
92
|
+
is reproduced here rather than improved on: a survey storing its options
|
|
93
|
+
semicolon-separated reads back as one option, and that is what the form
|
|
94
|
+
itself shows.
|
|
95
|
+
|
|
96
|
+
Empty for every other field type, where ``default_value`` is prose or a
|
|
97
|
+
prefilled answer rather than a list of choices.
|
|
98
|
+
"""
|
|
99
|
+
if self.type not in (SurveyFieldType.DROPDOWN, SurveyFieldType.CHECKBOXES):
|
|
100
|
+
return []
|
|
101
|
+
if not self.default_value:
|
|
102
|
+
return []
|
|
103
|
+
try:
|
|
104
|
+
parsed = json.loads(self.default_value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
parsed = None
|
|
107
|
+
if isinstance(parsed, list) and all(
|
|
108
|
+
isinstance(item, dict) and "name" in item for item in parsed
|
|
109
|
+
):
|
|
110
|
+
return [str(item["name"]) for item in parsed]
|
|
111
|
+
return [part for part in self.default_value.split(",") if part.strip()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SurveyResponseRow(Row):
|
|
115
|
+
"""One person's answers. ``GET /contest/survey/responses/{survey_id}/table``.
|
|
116
|
+
|
|
117
|
+
Every column here is the wire's own, and every one but :attr:`user_id` is a
|
|
118
|
+
string — including ``workstations`` and the two institution columns, which
|
|
119
|
+
arrive bracket-wrapped (``"[SPb ITMO]"``) rather than as arrays.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
#: The person id, matching ``personId`` on a team member. Arrives as a string.
|
|
123
|
+
user_id: int | None = None
|
|
124
|
+
username: str | None = None
|
|
125
|
+
first_name: str | None = None
|
|
126
|
+
last_name: str | None = None
|
|
127
|
+
badge_name: str | None = None
|
|
128
|
+
#: Not a :class:`~icpc.models.enums.Sex`: contests serve values such as
|
|
129
|
+
#: ``"Default sex"`` here, so this stays free text.
|
|
130
|
+
sex: str | None = None
|
|
131
|
+
teams: str | None = None
|
|
132
|
+
participation: str | None = None
|
|
133
|
+
workstations: str | None = None
|
|
134
|
+
institution_long_names: str | None = None
|
|
135
|
+
institution_short_names: str | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def answers(self) -> dict[str, str]:
|
|
139
|
+
"""Answers by :attr:`SurveyField.id`, as a string key.
|
|
140
|
+
|
|
141
|
+
The named columns above are the person, not the questionnaire; only the
|
|
142
|
+
numerically-keyed extras are answers. Unanswered fields are dropped,
|
|
143
|
+
which covers both the ``null`` and the empty-string forms the server
|
|
144
|
+
uses inconsistently.
|
|
145
|
+
"""
|
|
146
|
+
found: dict[str, str] = {}
|
|
147
|
+
for key, value in (self.model_extra or {}).items():
|
|
148
|
+
if key.isdigit() and value not in (None, ""):
|
|
149
|
+
found[key] = str(value)
|
|
150
|
+
return found
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def merge_answers(rows: Iterable[SurveyResponseRow]) -> dict[int, dict[str, str]]:
|
|
154
|
+
"""Answers by person id, merged across however many surveys.
|
|
155
|
+
|
|
156
|
+
Field ids are unique across a contest's surveys, so several surveys collapse
|
|
157
|
+
into one mapping without colliding.
|
|
158
|
+
"""
|
|
159
|
+
merged: dict[int, dict[str, str]] = {}
|
|
160
|
+
for row in rows:
|
|
161
|
+
if row.user_id is None:
|
|
162
|
+
continue
|
|
163
|
+
merged.setdefault(row.user_id, {}).update(row.answers)
|
|
164
|
+
return merged
|
|
@@ -46,6 +46,7 @@ from icpc.search._generated import (
|
|
|
46
46
|
from icpc.search.dsl import Direction, Filter, FilterMode, Q, SortKey
|
|
47
47
|
from icpc.search.endpoint import SearchEndpoint
|
|
48
48
|
from icpc.search.fields import Field
|
|
49
|
+
from icpc.search.surveys import SurveyResponseFields, survey_responses
|
|
49
50
|
|
|
50
51
|
__all__ = [
|
|
51
52
|
"CertificateFields",
|
|
@@ -64,6 +65,7 @@ __all__ = [
|
|
|
64
65
|
"StaffMemberFields",
|
|
65
66
|
"StaffTshirtFields",
|
|
66
67
|
"StandingsFields",
|
|
68
|
+
"SurveyResponseFields",
|
|
67
69
|
"TeamFields",
|
|
68
70
|
"TeamMemberFields",
|
|
69
71
|
"TeamSummaryFields",
|
|
@@ -96,4 +98,5 @@ __all__ = [
|
|
|
96
98
|
"site_team_certificates",
|
|
97
99
|
"site_team_members",
|
|
98
100
|
"site_teams",
|
|
101
|
+
"survey_responses",
|
|
99
102
|
]
|