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.
Files changed (91) hide show
  1. {icpc_api-0.1.0/icpc_api.egg-info → icpc_api-0.2.0}/PKG-INFO +1 -1
  2. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/__init__.py +1 -1
  3. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/__init__.py +2 -2
  4. icpc_api-0.2.0/icpc/api/survey.py +31 -0
  5. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/columns.py +3 -0
  6. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/main.py +62 -0
  7. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/client.py +38 -1
  8. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/domain.py +12 -0
  9. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/__init__.py +8 -0
  10. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/_generated.py +3 -3
  11. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/blobs.py +1 -2
  12. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/enums.py +34 -0
  13. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/mixins.py +1 -5
  14. icpc_api-0.2.0/icpc/models/surveys.py +164 -0
  15. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/__init__.py +3 -0
  16. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/_generated.py +3 -3
  17. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/endpoint.py +7 -0
  18. icpc_api-0.2.0/icpc/search/surveys.py +76 -0
  19. {icpc_api-0.1.0 → icpc_api-0.2.0/icpc_api.egg-info}/PKG-INFO +1 -1
  20. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/SOURCES.txt +5 -0
  21. {icpc_api-0.1.0 → icpc_api-0.2.0}/pyproject.toml +4 -0
  22. icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  23. icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
  24. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
  25. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
  26. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
  27. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
  28. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
  29. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
  30. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
  31. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
  32. icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
  33. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
  34. {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
  35. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
  36. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_columns.py +15 -0
  37. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_join.py +41 -0
  38. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_reference.py +2 -2
  39. icpc_api-0.2.0/tests/unit/test_surveys.py +117 -0
  40. icpc_api-0.2.0/uv.lock +504 -0
  41. icpc_api-0.1.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  42. icpc_api-0.1.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
  43. icpc_api-0.1.0/uv.lock +0 -504
  44. {icpc_api-0.1.0 → icpc_api-0.2.0}/LICENSE +0 -0
  45. {icpc_api-0.1.0 → icpc_api-0.2.0}/MANIFEST.in +0 -0
  46. {icpc_api-0.1.0 → icpc_api-0.2.0}/README.md +0 -0
  47. {icpc_api-0.1.0 → icpc_api-0.2.0}/THIRD-PARTY-LICENSES.md +0 -0
  48. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/common.py +0 -0
  49. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/contest.py +0 -0
  50. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/person.py +0 -0
  51. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/public.py +0 -0
  52. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/staff.py +0 -0
  53. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/api/team.py +0 -0
  54. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/__init__.py +0 -0
  55. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/cognito.py +0 -0
  56. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/flows.py +0 -0
  57. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/provider.py +0 -0
  58. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/srp.py +0 -0
  59. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/store.py +0 -0
  60. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/auth/tokens.py +0 -0
  61. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/__init__.py +0 -0
  62. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/cli/render.py +0 -0
  63. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/config.py +0 -0
  64. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/errors.py +0 -0
  65. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/facade/__init__.py +0 -0
  66. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/base.py +0 -0
  67. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/common.py +0 -0
  68. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/models/entities.py +0 -0
  69. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/py.typed +0 -0
  70. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/dsl.py +0 -0
  71. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/search/fields.py +0 -0
  72. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/__init__.py +0 -0
  73. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/_shared.py +0 -0
  74. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/async_client.py +0 -0
  75. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/operation.py +0 -0
  76. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc/transport/sync_client.py +0 -0
  77. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/dependency_links.txt +0 -0
  78. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/entry_points.txt +0 -0
  79. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/requires.txt +0 -0
  80. {icpc_api-0.1.0 → icpc_api-0.2.0}/icpc_api.egg-info/top_level.txt +0 -0
  81. {icpc_api-0.1.0 → icpc_api-0.2.0}/setup.cfg +0 -0
  82. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/__init__.py +0 -0
  83. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/__init__.py +0 -0
  84. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_auth.py +0 -0
  85. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_dsl.py +0 -0
  86. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_models.py +0 -0
  87. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_paging.py +0 -0
  88. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_srp.py +0 -0
  89. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_transport.py +0 -0
  90. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_transport_parity.py +0 -0
  91. {icpc_api-0.1.0 → icpc_api-0.2.0}/tests/unit/test_typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Unofficial Python client and CLI for the icpc.global API
5
5
  Author-email: Nikita Sychev <root@nsychev.ru>
6
6
  License-Expression: MIT
@@ -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.1.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: str | None = None
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: int | None = _Field(default=None, description="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: int | None = _Field(default=None, description="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`` / ``genericField`` — the answers to a contest's custom registration
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 custom registration questions.
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
  ]