docket-client 0.3.2__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 (188) hide show
  1. docket/__init__.py +8 -0
  2. docket/api/__init__.py +1 -0
  3. docket/api/access/__init__.py +1 -0
  4. docket/api/access/add_principal.py +193 -0
  5. docket/api/access/archive_role_assignment.py +182 -0
  6. docket/api/access/create_api_token.py +198 -0
  7. docket/api/access/create_application_principal.py +188 -0
  8. docket/api/access/create_role_assignment.py +194 -0
  9. docket/api/access/list_api_tokens.py +184 -0
  10. docket/api/access/list_principals.py +184 -0
  11. docket/api/access/list_role_assignments.py +200 -0
  12. docket/api/access/revoke_api_token.py +173 -0
  13. docket/api/identity/__init__.py +1 -0
  14. docket/api/identity/create_my_api_token.py +206 -0
  15. docket/api/identity/get_me.py +127 -0
  16. docket/api/identity/list_my_api_tokens.py +151 -0
  17. docket/api/identity/revoke_my_api_token.py +178 -0
  18. docket/api/recurring_acquisitions/__init__.py +1 -0
  19. docket/api/recurring_acquisitions/archive_recurring_acquisition.py +207 -0
  20. docket/api/recurring_acquisitions/create_recurring_acquisition.py +218 -0
  21. docket/api/recurring_acquisitions/get_recurring_acquisition.py +163 -0
  22. docket/api/recurring_acquisitions/list_recurring_acquisitions.py +248 -0
  23. docket/api/recurring_acquisitions/preview_recurring_acquisition.py +187 -0
  24. docket/api/recurring_acquisitions/preview_split_recurring_acquisition.py +232 -0
  25. docket/api/recurring_acquisitions/split_recurring_acquisition.py +258 -0
  26. docket/api/runs/__init__.py +1 -0
  27. docket/api/runs/get_run.py +160 -0
  28. docket/api/runs/list_runs.py +180 -0
  29. docket/api/runs/report_run_outcome.py +206 -0
  30. docket/api/scheduled_acquisitions/__init__.py +1 -0
  31. docket/api/scheduled_acquisitions/archive_scheduled_acquisition.py +203 -0
  32. docket/api/scheduled_acquisitions/create_scheduled_acquisition.py +185 -0
  33. docket/api/scheduled_acquisitions/get_resolved_settings.py +169 -0
  34. docket/api/scheduled_acquisitions/get_scheduled_acquisition.py +159 -0
  35. docket/api/scheduled_acquisitions/list_active_acquisitions.py +297 -0
  36. docket/api/scheduled_acquisitions/list_runs_for_scheduled_acquisition.py +191 -0
  37. docket/api/scheduled_acquisitions/list_scheduled_acquisitions.py +332 -0
  38. docket/api/scheduled_acquisitions/preview_scheduled_acquisition_edit.py +224 -0
  39. docket/api/scheduled_acquisitions/set_planning_status.py +199 -0
  40. docket/api/scheduled_acquisitions/start_run.py +221 -0
  41. docket/api/scheduled_acquisitions/update_scheduled_acquisition.py +226 -0
  42. docket/api/settings/__init__.py +1 -0
  43. docket/api/settings/list_behavior_curriculum_discriminator_values.py +188 -0
  44. docket/api/settings/list_behavior_curriculum_settings.py +199 -0
  45. docket/api/settings/list_fip_discriminator_values.py +184 -0
  46. docket/api/settings/list_fip_settings.py +195 -0
  47. docket/api/settings/list_live_behavior_curriculum_settings.py +233 -0
  48. docket/api/settings/list_live_fip_settings.py +225 -0
  49. docket/api/settings/preview_behavior_curriculum_settings.py +191 -0
  50. docket/api/settings/preview_fip_settings.py +177 -0
  51. docket/api/settings/push_behavior_curriculum_settings.py +222 -0
  52. docket/api/settings/push_fip_settings.py +206 -0
  53. docket/api/templates/__init__.py +1 -0
  54. docket/api/templates/archive_template.py +203 -0
  55. docket/api/templates/create_template.py +206 -0
  56. docket/api/templates/get_template.py +159 -0
  57. docket/api/templates/list_templates.py +174 -0
  58. docket/api/templates/update_template.py +234 -0
  59. docket/api/vocabulary/__init__.py +1 -0
  60. docket/api/vocabulary/list_instruments.py +165 -0
  61. docket/api/vocabulary/list_persons.py +132 -0
  62. docket/api/vocabulary/list_projects.py +132 -0
  63. docket/api/vocabulary/list_subjects.py +132 -0
  64. docket/api/vocabulary/lookup_person.py +166 -0
  65. docket/api/vocabulary/lookup_subject.py +166 -0
  66. docket/api/vocabulary/sync_vocabulary.py +148 -0
  67. docket/client.py +272 -0
  68. docket/errors.py +16 -0
  69. docket/models/__init__.py +221 -0
  70. docket/models/access_role.py +12 -0
  71. docket/models/access_role_nullable_type_1.py +12 -0
  72. docket/models/access_role_nullable_type_2_type_1.py +12 -0
  73. docket/models/access_role_nullable_type_3_type_1.py +12 -0
  74. docket/models/acquisition_variable.py +72 -0
  75. docket/models/acquisition_warning.py +126 -0
  76. docket/models/add_principal_request.py +83 -0
  77. docket/models/api_token_response.py +271 -0
  78. docket/models/api_token_status.py +10 -0
  79. docket/models/archive_recurring_acquisition_request.py +77 -0
  80. docket/models/archive_recurring_acquisition_response.py +83 -0
  81. docket/models/archive_scheduled_acquisition_request.py +78 -0
  82. docket/models/archive_template_request.py +66 -0
  83. docket/models/auth_mode.py +10 -0
  84. docket/models/behavior_curriculum_push_settings_request.py +85 -0
  85. docket/models/behavior_curriculum_settings.py +55 -0
  86. docket/models/behavior_curriculum_settings_preview_response.py +102 -0
  87. docket/models/behavior_curriculum_settings_preview_response_promoted.py +50 -0
  88. docket/models/behavior_curriculum_settings_version_response.py +160 -0
  89. docket/models/behavior_curriculum_settings_version_response_promoted.py +50 -0
  90. docket/models/caller_kind.py +11 -0
  91. docket/models/create_api_token_request.py +97 -0
  92. docket/models/create_application_principal_request.py +65 -0
  93. docket/models/create_personal_api_token_request.py +167 -0
  94. docket/models/create_recurring_acquisition_request.py +269 -0
  95. docket/models/create_role_assignment_request.py +99 -0
  96. docket/models/create_scheduled_acquisition_request.py +265 -0
  97. docket/models/create_template_request.py +323 -0
  98. docket/models/created_api_token_response.py +82 -0
  99. docket/models/effective_status.py +15 -0
  100. docket/models/fip_color.py +13 -0
  101. docket/models/fip_fiber.py +93 -0
  102. docket/models/fip_mode.py +9 -0
  103. docket/models/fip_push_settings_request.py +82 -0
  104. docket/models/fip_settings.py +91 -0
  105. docket/models/fip_settings_preview_response.py +97 -0
  106. docket/models/fip_settings_preview_response_promoted.py +50 -0
  107. docket/models/fip_settings_version_response.py +155 -0
  108. docket/models/fip_settings_version_response_promoted.py +50 -0
  109. docket/models/future_occurrences.py +9 -0
  110. docket/models/get_resolved_settings_response_200.py +65 -0
  111. docket/models/held_role_response.py +116 -0
  112. docket/models/http_validation_problem_details.py +204 -0
  113. docket/models/http_validation_problem_details_errors.py +59 -0
  114. docket/models/instrument_response.py +91 -0
  115. docket/models/kept_occurrence.py +85 -0
  116. docket/models/kept_occurrence_reason.py +10 -0
  117. docket/models/list_active_acquisitions_effective_status_item.py +15 -0
  118. docket/models/list_scheduled_acquisitions_planning_status.py +11 -0
  119. docket/models/me_caller.py +118 -0
  120. docket/models/me_permission.py +98 -0
  121. docket/models/me_response.py +158 -0
  122. docket/models/me_role.py +75 -0
  123. docket/models/occurrence_preview.py +121 -0
  124. docket/models/occurrence_summary.py +126 -0
  125. docket/models/occurrence_warnings.py +91 -0
  126. docket/models/permission_action.py +12 -0
  127. docket/models/person_assignment.py +74 -0
  128. docket/models/person_lookup_response.py +131 -0
  129. docket/models/person_response.py +103 -0
  130. docket/models/person_role.py +9 -0
  131. docket/models/personal_token_limits.py +92 -0
  132. docket/models/planning_status.py +11 -0
  133. docket/models/principal_kind.py +9 -0
  134. docket/models/principal_response.py +216 -0
  135. docket/models/problem_details.py +177 -0
  136. docket/models/project_response.py +75 -0
  137. docket/models/recurrence_exception_kind.py +13 -0
  138. docket/models/recurrence_exception_response.py +103 -0
  139. docket/models/recurrence_frequency.py +9 -0
  140. docket/models/recurrence_preview_response.py +101 -0
  141. docket/models/recurrence_rule.py +207 -0
  142. docket/models/recurring_acquisition_created_response.py +123 -0
  143. docket/models/recurring_acquisition_detail_response.py +105 -0
  144. docket/models/recurring_acquisition_response.py +326 -0
  145. docket/models/report_run_outcome_request.py +89 -0
  146. docket/models/resolution_mode.py +10 -0
  147. docket/models/resolved_settings.py +73 -0
  148. docket/models/role_assignment_response.py +157 -0
  149. docket/models/role_origin.py +10 -0
  150. docket/models/role_source_kind.py +10 -0
  151. docket/models/run_origin.py +9 -0
  152. docket/models/run_outcome.py +9 -0
  153. docket/models/run_response.py +181 -0
  154. docket/models/run_settings_snapshot.py +72 -0
  155. docket/models/run_start_response.py +91 -0
  156. docket/models/run_start_response_settings.py +65 -0
  157. docket/models/run_status.py +11 -0
  158. docket/models/scheduled_acquisition_edit_preview_response.py +150 -0
  159. docket/models/scheduled_acquisition_response.py +366 -0
  160. docket/models/set_planning_status_request.py +90 -0
  161. docket/models/settings_selector.py +138 -0
  162. docket/models/skipped_occurrence.py +85 -0
  163. docket/models/split_preview_response.py +129 -0
  164. docket/models/split_recurring_acquisition_request.py +261 -0
  165. docket/models/split_recurring_acquisition_response.py +159 -0
  166. docket/models/start_run_request.py +73 -0
  167. docket/models/subject_lookup_response.py +149 -0
  168. docket/models/subject_response.py +128 -0
  169. docket/models/sync_counts.py +80 -0
  170. docket/models/sync_response.py +81 -0
  171. docket/models/template_response.py +302 -0
  172. docket/models/update_scheduled_acquisition_request.py +235 -0
  173. docket/models/update_template_request.py +279 -0
  174. docket/models/weekday.py +14 -0
  175. docket/types.py +54 -0
  176. docket_auth/__init__.py +239 -0
  177. docket_client-0.3.2.dist-info/METADATA +233 -0
  178. docket_client-0.3.2.dist-info/RECORD +188 -0
  179. docket_client-0.3.2.dist-info/WHEEL +4 -0
  180. docket_client-0.3.2.dist-info/entry_points.txt +2 -0
  181. docket_mcp/__init__.py +6 -0
  182. docket_mcp/api.py +207 -0
  183. docket_mcp/config.py +57 -0
  184. docket_mcp/server.py +78 -0
  185. docket_mcp/signin.py +155 -0
  186. docket_mcp/slots.py +80 -0
  187. docket_mcp/times.py +160 -0
  188. docket_mcp/tools.py +1202 -0
docket_mcp/tools.py ADDED
@@ -0,0 +1,1202 @@
1
+ """The MCP tools: plain methods on `DocketTools`, registered by ``server.py``.
2
+
3
+ Read tools are anonymous. Every write tool takes ``dry_run`` (default true): a dry run asks the
4
+ server's preview endpoint (or, where the API has none, checks the schedule here) and returns what
5
+ would happen; only ``dry_run=false`` changes anything. Times in and out are local wall-clock plus
6
+ an IANA zone, with UTC alongside; see ``times.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import Annotated, Any, Literal
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from .api import DocketApi, DocketToolError
19
+ from .slots import busy_intervals, free_slots
20
+ from .times import (
21
+ day_bounds,
22
+ describe,
23
+ format_utc,
24
+ get_zone,
25
+ parse_date,
26
+ parse_instant,
27
+ parse_local_datetime,
28
+ parse_time,
29
+ )
30
+
31
+ __all__ = ["TOOL_NAMES", "DocketTools", "Person"]
32
+
33
+ DRY_RUN_NOTE = (
34
+ "Call with dry_run=true first and show the user the preview; "
35
+ "only call again with dry_run=false once they agree."
36
+ )
37
+ NEXT_STEP = (
38
+ "Show this preview to the user. If they agree, call again with the same arguments and "
39
+ "dry_run=false, plus the expected_version and idempotency_key from this preview (confirm_with)."
40
+ )
41
+
42
+ Weekday = Literal["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
43
+ SETTINGS_TYPES = {
44
+ "fip": "fip",
45
+ "behavior_curriculum": "behavior-curriculum",
46
+ "behavior-curriculum": "behavior-curriculum",
47
+ }
48
+
49
+ TOOL_NAMES = (
50
+ "list_rigs",
51
+ "list_projects",
52
+ "lookup_subject",
53
+ "lookup_person",
54
+ "get_schedule",
55
+ "get_acquisition",
56
+ "find_free_slots",
57
+ "list_series",
58
+ "get_series",
59
+ "list_templates",
60
+ "list_settings",
61
+ "list_runs",
62
+ "whoami",
63
+ "schedule_acquisition",
64
+ "move_acquisition",
65
+ "archive_acquisition",
66
+ "create_series",
67
+ "edit_series_from",
68
+ "archive_series",
69
+ "mark_outcome",
70
+ )
71
+
72
+
73
+ class Person(BaseModel):
74
+ person_id: str = Field(description="The person's username (see lookup_person).")
75
+ role: Literal["scientific_contact", "experimenter"] = "experimenter"
76
+
77
+
78
+ def _d(text: str) -> Any:
79
+ return Field(description=text)
80
+
81
+
82
+ SubjectArg = Annotated[str, _d("Subject id, e.g. '900001'.")]
83
+ RigArg = Annotated[str, _d("A rig id (e.g. 'E2E.BEH.A') or an instrument id; see list_rigs.")]
84
+ ProjectArg = Annotated[str, _d("Project id (its name); see list_projects.")]
85
+ ZoneArg = Annotated[
86
+ str | None,
87
+ _d(
88
+ "IANA time zone for the local times, e.g. 'America/Los_Angeles'. Default: DOCKET_TIME_ZONE."
89
+ ),
90
+ ]
91
+ DateArg = Annotated[str, _d("A local calendar date, e.g. '2026-03-08'.")]
92
+ DurationArg = Annotated[int, Field(ge=1, le=1440, description="Length in elapsed minutes.")]
93
+ DryRunArg = Annotated[
94
+ bool,
95
+ _d("True (default): only preview what would happen. False: really do it. " + NEXT_STEP),
96
+ ]
97
+ ExpectedVersionArg = Annotated[
98
+ int | None,
99
+ _d("The expected_version from the dry-run preview. Required when dry_run=false."),
100
+ ]
101
+ IdempotencyArg = Annotated[
102
+ str | None,
103
+ _d("The idempotency_key from the dry-run preview; a retry with it cannot repeat the change."),
104
+ ]
105
+ PeopleArg = Annotated[list[Person] | None, _d("People on the session, with their roles.")]
106
+ NotesArg = Annotated[str | None, _d("Operator instructions / notes shown to whoever runs it.")]
107
+
108
+
109
+ def _warnings(items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
110
+ return [
111
+ {"code": w["code"], "message": w["message"], "session_id": w.get("acquisition_id")}
112
+ for w in items or []
113
+ ]
114
+
115
+
116
+ def _flat_warnings(groups: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
117
+ out: list[dict[str, Any]] = []
118
+ for group in groups or []:
119
+ for w in _warnings(group.get("warnings")):
120
+ out.append({"date": group.get("occurrence_local_date"), **w})
121
+ return out
122
+
123
+
124
+ def _need_version(expected_version: int | None) -> int:
125
+ if expected_version is None:
126
+ raise DocketToolError(
127
+ "Confirming needs the expected_version from the dry-run preview: call with "
128
+ "dry_run=true first, show the user, then repeat with that expected_version."
129
+ )
130
+ return expected_version
131
+
132
+
133
+ def _changed_since_preview() -> DocketToolError:
134
+ return DocketToolError(
135
+ "This changed since your preview. Preview again (dry_run=true), show the user, "
136
+ "and confirm that one."
137
+ )
138
+
139
+
140
+ def _clashes(other: dict[str, Any], start: datetime, end: datetime) -> bool:
141
+ """The server's overlap rule: [start, end) against another booking; touching ends do not
142
+ overlap and an open-ended booking is a single point at its start."""
143
+ o_start = parse_instant(other["scheduled_start"])
144
+ if other.get("scheduled_end"):
145
+ return o_start < end and start < parse_instant(other["scheduled_end"])
146
+ return o_start < end and start <= o_start
147
+
148
+
149
+ def _hms(value: str) -> str:
150
+ return parse_time(value, "start_time").strftime("%H:%M:%S")
151
+
152
+
153
+ class DocketTools:
154
+ def __init__(self, api: DocketApi) -> None:
155
+ self.api = api
156
+ self._instruments: list[dict[str, Any]] | None = None
157
+ self._confirmed: dict[str, dict[str, Any]] = {} # idempotency key -> result
158
+
159
+ # -- helpers ---------------------------------------------------------------------------------
160
+
161
+ def _zone(self, name: str | None) -> ZoneInfo:
162
+ return get_zone(name or self.api.settings.time_zone)
163
+
164
+ def _instrument_list(self, refresh: bool = False) -> list[dict[str, Any]]:
165
+ if self._instruments is None or refresh:
166
+ self._instruments = self.api.get("/api/v1/instruments", what="the instruments")
167
+ return self._instruments or []
168
+
169
+ def _rig_of(self, instrument_id: str) -> str | None:
170
+ for instrument in self._instrument_list():
171
+ if instrument["id"] == instrument_id:
172
+ return instrument["rig_id"]
173
+ return None
174
+
175
+ def _match_rig(self, rig: str) -> list[dict[str, Any]]:
176
+ wanted = rig.strip().lower()
177
+ for refresh in (False, True):
178
+ instruments = self._instrument_list(refresh)
179
+ by_id = [i for i in instruments if i["id"].lower() == wanted]
180
+ if by_id:
181
+ return by_id
182
+ by_rig = [i for i in instruments if i["rig_id"].lower() == wanted]
183
+ if by_rig:
184
+ return by_rig
185
+ raise DocketToolError(
186
+ f"Unknown rig {rig!r}. Use list_rigs to see the rigs and instruments."
187
+ )
188
+
189
+ def _single_instrument(self, rig: str) -> dict[str, Any]:
190
+ matches = self._match_rig(rig)
191
+ if len(matches) > 1:
192
+ ids = ", ".join(i["id"] for i in matches)
193
+ raise DocketToolError(
194
+ f"Rig {rig!r} has several instruments ({ids}); pass the instrument id you mean."
195
+ )
196
+ return matches[0]
197
+
198
+ def _rig_filter(self, rig: str | None) -> dict[str, Any]:
199
+ """Query parameters selecting a rig's (or one instrument's) acquisitions."""
200
+ if not rig:
201
+ return {}
202
+ matches = self._match_rig(rig)
203
+ if len(matches) == 1 and matches[0]["id"].lower() == rig.strip().lower():
204
+ return {"instrument_id": matches[0]["id"]}
205
+ return {"rig_id": matches[0]["rig_id"]}
206
+
207
+ def _session_view(self, s: dict[str, Any], zone: ZoneInfo) -> dict[str, Any]:
208
+ return {
209
+ "id": s["id"],
210
+ "status": s["effective_status"],
211
+ "start": describe(s["scheduled_start"], zone),
212
+ "end": describe(s.get("scheduled_end"), zone),
213
+ "subject": s["subject_id"],
214
+ "rig": self._rig_of(s["instrument_id"]),
215
+ "instrument": s["instrument_id"],
216
+ "project": s["project_id"],
217
+ "task_name": s.get("task_name"),
218
+ "series_id": s.get("recurring_acquisition_id"),
219
+ "has_runs": s["has_runs"],
220
+ "archived": s.get("archived_at") is not None,
221
+ "version": s["version"],
222
+ }
223
+
224
+ def _list_sessions(self, **params: Any) -> list[dict[str, Any]]:
225
+ return self.api.get("/api/v1/scheduled-acquisitions", what="the schedule", **params)
226
+
227
+ def _session(self, session_id: int) -> dict[str, Any]:
228
+ return self.api.get(f"/api/v1/scheduled-acquisitions/{session_id}", what="the session")
229
+
230
+ @staticmethod
231
+ def _rule_view(rule: dict[str, Any]) -> dict[str, Any]:
232
+ return {
233
+ "frequency": rule["frequency"],
234
+ "weekdays": rule.get("weekdays"),
235
+ "interval": rule.get("interval", 1),
236
+ "start_date": rule["start_date"],
237
+ "start_time": rule["local_start_time"][:5],
238
+ "duration_minutes": rule["duration_minutes"],
239
+ "time_zone": rule["time_zone"],
240
+ "count": rule.get("count"),
241
+ "until": rule.get("until"),
242
+ }
243
+
244
+ def _series_view(self, d: dict[str, Any], zone: ZoneInfo) -> dict[str, Any]:
245
+ return {
246
+ "id": d["id"],
247
+ "subject": d["subject_id"],
248
+ "rig": self._rig_of(d["instrument_id"]),
249
+ "instrument": d["instrument_id"],
250
+ "project": d["project_id"],
251
+ "rule": self._rule_view(d["rule"]),
252
+ "next_start": describe(d.get("next_start"), zone),
253
+ "remaining": d["remaining"],
254
+ "ended_on": d.get("ended_on"),
255
+ "superseded_by": d.get("superseded_by_id"),
256
+ "archived": d.get("archived_at") is not None,
257
+ "version": d["version"],
258
+ }
259
+
260
+ def _nearby(self, start: datetime, end: datetime, **query: Any) -> list[dict[str, Any]]:
261
+ lo, hi = format_utc(start - timedelta(days=1)), format_utc(end + timedelta(days=1))
262
+ return [
263
+ o
264
+ for o in self._list_sessions(start=lo, end=hi, **query)
265
+ if o["planning_status"] != "cancelled" and not o.get("archived_at")
266
+ ]
267
+
268
+ def _overlaps(
269
+ self, start: datetime, end: datetime, *, instrument_id: str, subject: str
270
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
271
+ """``(warnings, advisory)`` for a new session, found by looking at the schedule.
272
+
273
+ The API has no single-session preview, so the server's rule is copied (same instrument
274
+ only; see ``_clashes``) for the warnings the server would return on create. Clashes of
275
+ the same subject on other instruments are not server warnings: they come as advisory.
276
+ """
277
+ warnings = [
278
+ {
279
+ "code": "instrument_overlap",
280
+ "message": f"Overlaps session {o['id']} (subject {o['subject_id']}) on {instrument_id}",
281
+ "session_id": o["id"],
282
+ }
283
+ for o in self._nearby(start, end, instrument_id=instrument_id)
284
+ if _clashes(o, start, end)
285
+ ]
286
+ advisory = [
287
+ {
288
+ "code": "subject_overlap",
289
+ "message": f"Subject {subject} is also on session {o['id']} ({o['instrument_id']})",
290
+ "session_id": o["id"],
291
+ }
292
+ for o in self._nearby(start, end, subject_id=subject)
293
+ if o["instrument_id"] != instrument_id and _clashes(o, start, end)
294
+ ]
295
+ return warnings, advisory
296
+
297
+ def _template_defaults(self, template_id: int | None) -> dict[str, Any]:
298
+ if template_id is None:
299
+ return {}
300
+ t = self.api.get(f"/api/v1/templates/{template_id}", what="the template")
301
+ return {
302
+ "operator_instructions": t.get("operator_instructions") or "",
303
+ "people": t.get("people") or [],
304
+ "type_refs": t.get("type_refs") or [],
305
+ "variables": t.get("variables") or [],
306
+ }
307
+
308
+ @staticmethod
309
+ def _people_body(people: list[Person] | None) -> list[dict[str, str]] | None:
310
+ if people is None:
311
+ return None
312
+ return [{"person_id": p.person_id, "role": p.role} for p in people]
313
+
314
+ # -- read tools ------------------------------------------------------------------------------
315
+
316
+ def list_rigs(
317
+ self, search: Annotated[str | None, _d("Substring of a rig or instrument id.")] = None
318
+ ) -> dict[str, Any]:
319
+ """List rigs with their instruments. Sessions are booked on a rig (its instrument)."""
320
+ rigs: dict[str, list[str]] = {}
321
+ for i in self._instrument_list(refresh=True):
322
+ rigs.setdefault(i["rig_id"], []).append(i["id"])
323
+ needle = (search or "").lower()
324
+ out = [
325
+ {"rig": rig, "instruments": ids}
326
+ for rig, ids in sorted(rigs.items())
327
+ if not needle or needle in rig.lower() or any(needle in i.lower() for i in ids)
328
+ ]
329
+ return {"count": len(out), "rigs": out}
330
+
331
+ def list_projects(
332
+ self, search: Annotated[str | None, _d("Substring of a project id.")] = None
333
+ ) -> dict[str, Any]:
334
+ """List project ids that sessions can be booked under."""
335
+ needle = (search or "").lower()
336
+ ids = sorted(
337
+ p["id"]
338
+ for p in self.api.get("/api/v1/projects", what="the projects")
339
+ if needle in p["id"].lower()
340
+ )
341
+ return {"count": len(ids), "projects": ids}
342
+
343
+ def lookup_subject(self, subject_id: SubjectArg) -> dict[str, Any]:
344
+ """Check that a subject exists and return its sex, date of birth and genotype."""
345
+ return self.api.get("/api/v1/subjects/lookup", what="the subject", subject_id=subject_id)
346
+
347
+ def lookup_person(
348
+ self, username: Annotated[str, _d("A person's username, e.g. 'jane.doe'.")]
349
+ ) -> dict[str, Any]:
350
+ """Check that a person exists and return their canonical username, name and email."""
351
+ return self.api.get("/api/v1/persons/lookup", what="the person", username=username)
352
+
353
+ def get_schedule(
354
+ self,
355
+ start_date: DateArg,
356
+ end_date: Annotated[str, _d("Last local date, inclusive.")],
357
+ rig: Annotated[str | None, _d("Only this rig or instrument.")] = None,
358
+ subject: Annotated[str | None, _d("Only this subject.")] = None,
359
+ person: Annotated[str | None, _d("Only sessions this person is on.")] = None,
360
+ include_archived: Annotated[bool, _d("Also list archived sessions.")] = False,
361
+ time_zone: ZoneArg = None,
362
+ ) -> dict[str, Any]:
363
+ """Sessions between two local dates: status, times, subject, rig, task, series and runs."""
364
+ zone = self._zone(time_zone)
365
+ lo, hi = day_bounds(
366
+ parse_date(start_date, "start_date"), parse_date(end_date, "end_date"), zone
367
+ )
368
+ sessions = self._list_sessions(
369
+ start=format_utc(lo),
370
+ end=format_utc(hi),
371
+ subject_id=subject,
372
+ include_archived=True if include_archived else None,
373
+ limit=500,
374
+ **self._rig_filter(rig),
375
+ )
376
+ truncated = len(sessions) >= 500
377
+ if person:
378
+ sessions = [
379
+ s for s in sessions if any(p["person_id"] == person for p in s.get("people", []))
380
+ ]
381
+ sessions.sort(key=lambda s: s["scheduled_start"])
382
+ return {
383
+ "time_zone": zone.key,
384
+ "count": len(sessions),
385
+ **({"truncated": True} if truncated else {}),
386
+ "sessions": [self._session_view(s, zone) for s in sessions],
387
+ }
388
+
389
+ def get_acquisition(
390
+ self,
391
+ id: Annotated[int, _d("Session (scheduled acquisition) id.")],
392
+ time_zone: ZoneArg = None,
393
+ ) -> dict[str, Any]:
394
+ """Full detail of one session: people, notes, variables, resolved settings and runs."""
395
+ zone = self._zone(time_zone)
396
+ s = self._session(id)
397
+ try:
398
+ settings = self.api.get(f"/api/v1/scheduled-acquisitions/{id}/resolved-settings")
399
+ except DocketToolError as error:
400
+ settings = {"unavailable": str(error)}
401
+ runs = self.api.get(f"/api/v1/scheduled-acquisitions/{id}/runs", what="the runs")
402
+ return {
403
+ **self._session_view(s, zone),
404
+ "planning_status": s["planning_status"],
405
+ "notes": s["operator_instructions"],
406
+ "people": [{"person": p["person_id"], "role": p["role"]} for p in s["people"]],
407
+ "settings_selection": s["type_refs"],
408
+ "variables": {v["key"]: v["value"] for v in s["variables"]},
409
+ "warnings": _warnings(s.get("warnings")),
410
+ "created_by": s["created_by"],
411
+ "resolved_settings": settings,
412
+ "runs": [self._run_view(r, zone) for r in runs],
413
+ "time_zone": zone.key,
414
+ }
415
+
416
+ @staticmethod
417
+ def _run_view(r: dict[str, Any], zone: ZoneInfo) -> dict[str, Any]:
418
+ # The run token identifies a run and is deliberately left out.
419
+ return {
420
+ "id": r["id"],
421
+ "session_id": r["scheduled_acquisition_id"],
422
+ "status": r["status"],
423
+ "origin": r["origin"],
424
+ "started": describe(r["started_at"], zone),
425
+ "ended": describe(r.get("ended_at"), zone),
426
+ "error": r.get("error_detail"),
427
+ "started_by": r["started_by"],
428
+ }
429
+
430
+ def find_free_slots(
431
+ self,
432
+ rig: RigArg,
433
+ start_date: DateArg,
434
+ end_date: Annotated[str, _d("Last local date, inclusive.")],
435
+ duration_minutes: DurationArg,
436
+ earliest: Annotated[str, _d("Earliest local start time each day, 24h.")] = "08:00",
437
+ latest: Annotated[str, _d("Latest local end time each day, 24h.")] = "18:00",
438
+ time_zone: ZoneArg = None,
439
+ ) -> dict[str, Any]:
440
+ """Gaps on a rig long enough for a session, computed from its schedule.
441
+
442
+ A rig with several instruments gets each instrument's gaps separately (they are
443
+ separate machines). Cancelled and archived sessions do not block. Deliberately unlike
444
+ the server's overlap warning (which treats an open-ended session as a single point), a
445
+ session with no end is assumed to run to the end of its local day, since its length is
446
+ unknown and a slot that collides with it would not be usable. A session with no end is assumed to run to
447
+ the end of its day. Gaps are free windows; a session can start anywhere inside one.
448
+ """
449
+ zone = self._zone(time_zone)
450
+ first, last = parse_date(start_date, "start_date"), parse_date(end_date, "end_date")
451
+ lo, hi = day_bounds(first, last, zone)
452
+ pad = timedelta(days=1)
453
+ sessions = self._list_sessions(
454
+ start=format_utc(lo - pad), end=format_utc(hi), limit=1000, **self._rig_filter(rig)
455
+ )
456
+ # Instruments of one rig are separate machines: each has its own calendar.
457
+ out: list[dict[str, Any]] = []
458
+ for instrument in self._match_rig(rig):
459
+ mine = [s for s in sessions if s["instrument_id"] == instrument["id"]]
460
+ try:
461
+ slots = free_slots(
462
+ busy_intervals(mine, zone),
463
+ first,
464
+ last,
465
+ duration_minutes,
466
+ parse_time(earliest, "earliest"),
467
+ parse_time(latest, "latest"),
468
+ zone,
469
+ )
470
+ except ValueError as error:
471
+ raise DocketToolError(str(error)) from None
472
+ out += [
473
+ {
474
+ "instrument": instrument["id"],
475
+ "start": describe(s, zone),
476
+ "end": describe(e, zone),
477
+ "free_minutes": int((e - s).total_seconds() // 60),
478
+ }
479
+ for s, e in slots
480
+ ]
481
+ out.sort(key=lambda slot: (slot["start"]["utc"], slot["instrument"]))
482
+ notes = []
483
+ if len(sessions) >= 1000:
484
+ notes.append("The schedule was cut off at 1000 sessions: narrow the dates.")
485
+ if any(not x.get("scheduled_end") and not x.get("archived_at") for x in sessions):
486
+ notes.append("Open-ended sessions are assumed to run to the end of their day.")
487
+ return {
488
+ "rig": rig,
489
+ "time_zone": zone.key,
490
+ "needed_minutes": duration_minutes,
491
+ "count": len(out),
492
+ **({"truncated": True} if len(sessions) >= 1000 else {}),
493
+ **({"notes": notes} if notes else {}),
494
+ "slots": out,
495
+ }
496
+
497
+ def list_series(
498
+ self,
499
+ subject: Annotated[str | None, _d("Only this subject.")] = None,
500
+ rig: Annotated[str | None, _d("Only this rig or instrument.")] = None,
501
+ time_zone: ZoneArg = None,
502
+ ) -> dict[str, Any]:
503
+ """List recurring series (a finite pattern of sessions) and their rules."""
504
+ zone = self._zone(time_zone)
505
+ wanted = {i["id"] for i in self._match_rig(rig)} if rig else None
506
+ items = self.api.get(
507
+ "/api/v1/recurring-acquisitions", what="the series", subject_id=subject, limit=200
508
+ )
509
+ views = [
510
+ self._series_view(d, zone)
511
+ for d in items
512
+ if wanted is None or d["instrument_id"] in wanted
513
+ ]
514
+ return {"time_zone": zone.key, "count": len(views), "series": views}
515
+
516
+ def get_series(
517
+ self, id: Annotated[int, _d("Series id.")], time_zone: ZoneArg = None
518
+ ) -> dict[str, Any]:
519
+ """One series: its rule, how many sessions it made and which dates are exceptions."""
520
+ zone = self._zone(time_zone)
521
+ d = self.api.get(f"/api/v1/recurring-acquisitions/{id}", what="the series")
522
+ occ = d["occurrences"]
523
+ return {
524
+ **self._series_view(d["definition"], zone),
525
+ "sessions": {
526
+ "count": occ["count"],
527
+ "first": describe(occ.get("first_scheduled_start"), zone),
528
+ "last": describe(occ.get("last_scheduled_start"), zone),
529
+ "ids": occ["ids"],
530
+ },
531
+ "exceptions": d["exceptions"],
532
+ "notes": d["definition"]["operator_instructions"],
533
+ }
534
+
535
+ def list_templates(self) -> dict[str, Any]:
536
+ """List scheduling templates (named defaults: people, notes, settings, duration)."""
537
+ items = self.api.get("/api/v1/templates", what="the templates")
538
+ return {
539
+ "count": len(items),
540
+ "templates": [
541
+ {
542
+ "id": t["id"],
543
+ "name": t["name"],
544
+ "subject": t.get("subject_id"),
545
+ "rig": t.get("instrument_id"),
546
+ "project": t.get("project_id"),
547
+ "duration_minutes": t.get("duration_minutes"),
548
+ "start_time": (t.get("local_start_time") or "")[:5] or None,
549
+ "people": [{"person": p["person_id"], "role": p["role"]} for p in t["people"]],
550
+ "notes": t.get("operator_instructions"),
551
+ }
552
+ for t in items
553
+ ],
554
+ }
555
+
556
+ def list_settings(
557
+ self,
558
+ acquisition_type: Annotated[str, _d("'fip' or 'behavior_curriculum'.")],
559
+ subject: Annotated[str | None, _d("Only this subject.")] = None,
560
+ live_only: Annotated[bool, _d("Only the settings versions currently in force.")] = False,
561
+ limit: Annotated[int, Field(ge=1, le=200, description="Most versions to return.")] = 20,
562
+ ) -> Any:
563
+ """List stored settings versions for an acquisition type."""
564
+ slug = SETTINGS_TYPES.get(acquisition_type.strip().lower())
565
+ if slug is None:
566
+ raise DocketToolError(
567
+ f"Unknown acquisition type {acquisition_type!r}; use 'fip' or 'behavior_curriculum'."
568
+ )
569
+ suffix = "/live" if live_only else ""
570
+ return self.api.get(
571
+ f"/api/v1/settings/{slug}{suffix}", what="the settings", subject_id=subject, limit=limit
572
+ )
573
+
574
+ def list_runs(
575
+ self,
576
+ acquisition_id: Annotated[int | None, _d("Runs of this session.")] = None,
577
+ subject: Annotated[str | None, _d("Runs of this subject's sessions.")] = None,
578
+ rig: Annotated[str | None, _d("Runs on this rig or instrument.")] = None,
579
+ start_date: Annotated[
580
+ str | None, _d("Only runs started on or after this local date.")
581
+ ] = None,
582
+ end_date: Annotated[
583
+ str | None, _d("Only runs started on or before this local date.")
584
+ ] = None,
585
+ time_zone: ZoneArg = None,
586
+ ) -> dict[str, Any]:
587
+ """List runs (an attempt to execute a session) by session, subject or rig, newest first."""
588
+ zone = self._zone(time_zone)
589
+ truncated = False
590
+ if acquisition_id is not None:
591
+ runs = self.api.get(
592
+ f"/api/v1/scheduled-acquisitions/{acquisition_id}/runs", what="the runs"
593
+ )
594
+ elif subject or rig:
595
+ sessions = self._list_sessions(subject_id=subject, limit=200, **self._rig_filter(rig))
596
+ runs = []
597
+ with_runs = [s for s in sessions if s["has_runs"]]
598
+ truncated = len(sessions) >= 200 or len(with_runs) > 50
599
+ for s in with_runs[:50]:
600
+ runs += self.api.get(
601
+ f"/api/v1/scheduled-acquisitions/{s['id']}/runs", what="the runs"
602
+ )
603
+ else:
604
+ runs = self.api.get("/api/v1/runs", what="the runs", limit=200)
605
+ truncated = len(runs) >= 200
606
+ if start_date or end_date:
607
+ lo = (
608
+ day_bounds(
609
+ parse_date(start_date or end_date or "", "start_date"),
610
+ parse_date(start_date or end_date or "", "start_date"),
611
+ zone,
612
+ )[0]
613
+ if start_date
614
+ else None
615
+ )
616
+ hi = (
617
+ day_bounds(
618
+ parse_date(end_date or start_date or "", "end_date"),
619
+ parse_date(end_date or start_date or "", "end_date"),
620
+ zone,
621
+ )[1]
622
+ if end_date
623
+ else None
624
+ )
625
+ runs = [
626
+ r
627
+ for r in runs
628
+ if (lo is None or parse_instant(r["started_at"]) >= lo)
629
+ and (hi is None or parse_instant(r["started_at"]) < hi)
630
+ ]
631
+ runs.sort(key=lambda r: r["started_at"], reverse=True)
632
+ return {
633
+ "time_zone": zone.key,
634
+ "count": len(runs),
635
+ **({"truncated": True} if truncated else {}),
636
+ "runs": [self._run_view(r, zone) for r in runs],
637
+ }
638
+
639
+ def whoami(self) -> dict[str, Any]:
640
+ """The server's auth mode and who you are to it: caller, roles (and their source), grants.
641
+
642
+ Shows you as yourself when a credential is available without interaction (an API token,
643
+ client credentials or a cached sign-in); otherwise as anonymous. It never starts a sign-in.
644
+ """
645
+ me, signed_in = self.api.me_as_caller()
646
+ return {
647
+ **me,
648
+ "signed_in": signed_in,
649
+ "api_url": self.api.settings.api_url,
650
+ "time_zone": self.api.settings.time_zone,
651
+ }
652
+
653
+ # -- write tools -----------------------------------------------------------------------------
654
+
655
+ def schedule_acquisition(
656
+ self,
657
+ subject: SubjectArg,
658
+ rig: RigArg,
659
+ project: ProjectArg,
660
+ start: Annotated[str, _d("Local start, e.g. '2026-03-08T09:30' (no offset).")],
661
+ duration_minutes: DurationArg,
662
+ people: PeopleArg = None,
663
+ notes: NotesArg = None,
664
+ template_id: Annotated[int | None, _d("Start from this template's defaults.")] = None,
665
+ time_zone: ZoneArg = None,
666
+ dry_run: DryRunArg = True,
667
+ idempotency_key: IdempotencyArg = None,
668
+ ) -> dict[str, Any]:
669
+ """Schedule one acquisition session. Call with dry_run=true first and show the user the preview.
670
+
671
+ The preview checks the subject, project and people exist and looks for overlaps on the
672
+ rig (the API has no preview for a single session, so this is estimated here with the
673
+ server's rule; the server checks again when it creates it). Confirm with dry_run=false
674
+ and the preview's idempotency_key: repeating the call cannot book it twice.
675
+ """
676
+ zone = self._zone(time_zone)
677
+ parsed = parse_local_datetime(start, zone)
678
+ end = parsed.utc + timedelta(minutes=duration_minutes)
679
+ instrument = self._single_instrument(rig)
680
+ defaults = self._template_defaults(template_id)
681
+ body: dict[str, Any] = {
682
+ "subject_id": subject,
683
+ "instrument_id": instrument["id"],
684
+ "project_id": project,
685
+ "scheduled_start": format_utc(parsed.utc),
686
+ "scheduled_end": format_utc(end),
687
+ "operator_instructions": notes
688
+ if notes is not None
689
+ else defaults.get("operator_instructions", ""),
690
+ "people": self._people_body(people)
691
+ if people is not None
692
+ else defaults.get("people", []),
693
+ "type_refs": defaults.get("type_refs", []),
694
+ "variables": defaults.get("variables", []),
695
+ }
696
+ summary = {
697
+ "subject": subject,
698
+ "rig": instrument["rig_id"],
699
+ "instrument": instrument["id"],
700
+ "project": project,
701
+ "start": describe(parsed.utc, zone),
702
+ "end": describe(end, zone),
703
+ "duration_minutes": duration_minutes,
704
+ "people": [{"person": p["person_id"], "role": p["role"]} for p in body["people"]],
705
+ "notes": body["operator_instructions"],
706
+ "time_zone": zone.key,
707
+ }
708
+ extra = (
709
+ ["That local time happens twice (clocks go back): the first occurrence was used."]
710
+ if parsed.ambiguous
711
+ else []
712
+ )
713
+ if dry_run:
714
+ key = str(uuid.uuid4())
715
+ warnings, advisory = self._overlaps(
716
+ parsed.utc, end, instrument_id=instrument["id"], subject=subject
717
+ )
718
+ return {
719
+ "dry_run": True,
720
+ "would_create": summary,
721
+ "warnings": warnings,
722
+ "advisory": advisory,
723
+ "warnings_note": "estimated here; the server re-checks on create",
724
+ "problems": self._problems(body),
725
+ "notes": extra,
726
+ "idempotency_key": key,
727
+ "confirm_with": {"dry_run": False, "idempotency_key": key},
728
+ "next_step": NEXT_STEP,
729
+ }
730
+ key = idempotency_key or str(uuid.uuid4())
731
+ if key in self._confirmed: # the model retried this confirmation
732
+ return {**self._confirmed[key], "replayed": True}
733
+ # The create endpoint takes no idempotency key, so a retry after a timeout is caught by
734
+ # looking for the identical session that an earlier attempt may have made.
735
+ for other in self._nearby(parsed.utc, end, instrument_id=instrument["id"]):
736
+ if (
737
+ other["subject_id"] == subject
738
+ and other["scheduled_start"] == body["scheduled_start"]
739
+ and other["scheduled_end"] == body["scheduled_end"]
740
+ and other["project_id"] == project
741
+ ):
742
+ return {
743
+ "dry_run": False,
744
+ "already_exists": self._session_view(other, zone),
745
+ "note": "An identical session already exists, so nothing was created.",
746
+ }
747
+ created = self.api.post(
748
+ "/api/v1/scheduled-acquisitions", body, action="schedule an acquisition"
749
+ )
750
+ result = {
751
+ "dry_run": False,
752
+ "created": self._session_view(created, zone),
753
+ "warnings": _warnings(created.get("warnings")),
754
+ "notes": extra,
755
+ }
756
+ self._confirmed[key] = result
757
+ return result
758
+
759
+ def _problems(self, body: dict[str, Any]) -> list[str]:
760
+ """Things that would make creation fail: unknown subject, project or person."""
761
+ problems: list[str] = []
762
+ subject = self.api.get("/api/v1/subjects/lookup", subject_id=body["subject_id"])
763
+ if not subject.get("valid"):
764
+ problems.append(
765
+ f"Subject {body['subject_id']!r}: {subject.get('detail') or 'not found'}"
766
+ )
767
+ projects = {p["id"] for p in self.api.get("/api/v1/projects")}
768
+ if body["project_id"] not in projects:
769
+ problems.append(f"Unknown project {body['project_id']!r} (see list_projects).")
770
+ for person in body["people"]:
771
+ looked = self.api.get("/api/v1/persons/lookup", username=person["person_id"])
772
+ if not looked.get("valid"):
773
+ problems.append(
774
+ f"Person {person['person_id']!r}: {looked.get('detail') or 'not found'}"
775
+ )
776
+ return problems
777
+
778
+ def move_acquisition(
779
+ self,
780
+ id: Annotated[int, _d("Session id (see get_schedule).")],
781
+ start: Annotated[str | None, _d("New local start, e.g. '2026-03-08T09:30'.")] = None,
782
+ duration_minutes: Annotated[
783
+ int | None, Field(ge=1, le=1440, description="New length in minutes.")
784
+ ] = None,
785
+ rig: Annotated[str | None, _d("Move to this rig or instrument.")] = None,
786
+ time_zone: ZoneArg = None,
787
+ dry_run: DryRunArg = True,
788
+ expected_version: ExpectedVersionArg = None,
789
+ ) -> dict[str, Any]:
790
+ """Change a session's start, length or rig. Call with dry_run=true first and show the user the preview.
791
+
792
+ Confirm with dry_run=false and the preview's expected_version: if the session changed
793
+ since the preview the call is refused and you must preview again.
794
+ """
795
+ zone = self._zone(time_zone)
796
+ if start is None and duration_minutes is None and rig is None:
797
+ raise DocketToolError("Nothing to change: give start, duration_minutes or rig.")
798
+ if not dry_run:
799
+ _need_version(expected_version)
800
+ current = self._session(id)
801
+ if not dry_run and current["version"] != expected_version:
802
+ raise _changed_since_preview()
803
+ cur_start = parse_instant(current["scheduled_start"])
804
+ cur_end = parse_instant(current["scheduled_end"]) if current.get("scheduled_end") else None
805
+ body: dict[str, Any] = {"expected_version": current["version"]}
806
+ extra: list[str] = []
807
+ new_start = cur_start
808
+ if start is not None:
809
+ parsed = parse_local_datetime(start, zone)
810
+ new_start = parsed.utc
811
+ body["scheduled_start"] = format_utc(new_start)
812
+ if parsed.ambiguous:
813
+ extra.append(
814
+ "That local time happens twice (clocks go back): the first occurrence was used."
815
+ )
816
+ if duration_minutes is not None:
817
+ body["scheduled_end"] = format_utc(new_start + timedelta(minutes=duration_minutes))
818
+ elif start is not None and cur_end is not None:
819
+ body["scheduled_end"] = format_utc(new_start + (cur_end - cur_start))
820
+ if rig is not None:
821
+ body["instrument_id"] = self._single_instrument(rig)["id"]
822
+ path = f"/api/v1/scheduled-acquisitions/{id}"
823
+ if dry_run:
824
+ preview = self.api.preview(f"{path}/edit-preview", body)
825
+ return {
826
+ "dry_run": True,
827
+ "session_id": id,
828
+ "before": self._session_view(current, zone),
829
+ "after": {
830
+ "start": describe(preview["scheduled_start"], zone),
831
+ "end": describe(preview.get("scheduled_end"), zone),
832
+ "instrument": preview["instrument_id"],
833
+ "rig": self._rig_of(preview["instrument_id"]),
834
+ },
835
+ "part_of_series": preview.get("recurring_acquisition_id"),
836
+ "warnings": _warnings(preview.get("warnings")),
837
+ "notes": extra,
838
+ "expected_version": current["version"],
839
+ "confirm_with": {"dry_run": False, "expected_version": current["version"]},
840
+ "next_step": NEXT_STEP,
841
+ }
842
+ updated = self.api.patch(path, body, action="move this session")
843
+ return {
844
+ "dry_run": False,
845
+ "moved": self._session_view(updated, zone),
846
+ "warnings": _warnings(updated.get("warnings")),
847
+ "notes": extra,
848
+ }
849
+
850
+ def archive_acquisition(
851
+ self,
852
+ id: Annotated[int, _d("Session id (see get_schedule).")],
853
+ time_zone: ZoneArg = None,
854
+ dry_run: DryRunArg = True,
855
+ expected_version: ExpectedVersionArg = None,
856
+ ) -> dict[str, Any]:
857
+ """Archive (cancel and hide) a session; nothing is ever hard-deleted. Call with dry_run=true first and show the user the preview.
858
+
859
+ Confirm with dry_run=false and the preview's expected_version.
860
+ """
861
+ zone = self._zone(time_zone)
862
+ if not dry_run:
863
+ version = _need_version(expected_version)
864
+ archived = self.api.post(
865
+ f"/api/v1/scheduled-acquisitions/{id}/archive",
866
+ {"expected_version": version},
867
+ action="archive this session",
868
+ )
869
+ return {"dry_run": False, "archived": self._session_view(archived, zone)}
870
+ current = self._session(id)
871
+ view = self._session_view(current, zone)
872
+ return {
873
+ "dry_run": True,
874
+ "would_archive": view,
875
+ "already_archived": view["archived"],
876
+ "part_of_series": current.get("recurring_acquisition_id"),
877
+ "expected_version": current["version"],
878
+ "confirm_with": {"dry_run": False, "expected_version": current["version"]},
879
+ "next_step": NEXT_STEP,
880
+ }
881
+
882
+ def mark_outcome(
883
+ self,
884
+ acquisition_id: Annotated[int, _d("Session id.")],
885
+ outcome: Annotated[
886
+ Literal["succeeded", "failed", "cancelled"], _d("How the session ended up.")
887
+ ],
888
+ time_zone: ZoneArg = None,
889
+ dry_run: DryRunArg = True,
890
+ expected_version: ExpectedVersionArg = None,
891
+ ) -> dict[str, Any]:
892
+ """Record a session's outcome by hand (succeeded, failed or cancelled). Call with dry_run=true first and show the user the preview.
893
+
894
+ Confirm with dry_run=false and the preview's expected_version.
895
+ """
896
+ zone = self._zone(time_zone)
897
+ if not dry_run:
898
+ version = _need_version(expected_version)
899
+ updated = self.api.post(
900
+ f"/api/v1/scheduled-acquisitions/{acquisition_id}/planning-status",
901
+ {"planning_status": outcome, "expected_version": version},
902
+ action="set this session's outcome",
903
+ )
904
+ return {"dry_run": False, "session": self._session_view(updated, zone)}
905
+ current = self._session(acquisition_id)
906
+ return {
907
+ "dry_run": True,
908
+ "session": self._session_view(current, zone),
909
+ "would_set": outcome,
910
+ "currently": current["planning_status"],
911
+ "expected_version": current["version"],
912
+ "confirm_with": {"dry_run": False, "expected_version": current["version"]},
913
+ "next_step": NEXT_STEP,
914
+ }
915
+
916
+ # -- series ----------------------------------------------------------------------------------
917
+
918
+ @staticmethod
919
+ def _end_rule(count: int | None, until: str | None) -> dict[str, Any]:
920
+ if (count is None) == (until is None):
921
+ raise DocketToolError(
922
+ "Give exactly one of count or until: a series is never open-ended."
923
+ )
924
+ return {"count": count, "until": parse_date(until, "until").isoformat() if until else None}
925
+
926
+ def _preview_view(
927
+ self, preview: dict[str, Any], zone: ZoneInfo, limit: int = 31
928
+ ) -> dict[str, Any]:
929
+ occ = preview["occurrences"]
930
+ return {
931
+ "session_count": len(occ),
932
+ "sessions": [
933
+ {
934
+ "date": o["occurrence_local_date"],
935
+ "start": describe(o["scheduled_start"], zone),
936
+ "end": describe(o["scheduled_end"], zone),
937
+ }
938
+ for o in occ[:limit]
939
+ ],
940
+ "sessions_not_listed": max(0, len(occ) - limit),
941
+ "warnings": [
942
+ {"date": o["occurrence_local_date"], **w}
943
+ for o in occ
944
+ for w in _warnings(o.get("warnings"))
945
+ ],
946
+ "skipped_dates": [
947
+ {"date": s["occurrence_local_date"], "reason": s["reason"], "message": s["message"]}
948
+ for s in preview.get("skipped", [])
949
+ ],
950
+ }
951
+
952
+ def create_series(
953
+ self,
954
+ subject: SubjectArg,
955
+ rig: RigArg,
956
+ project: ProjectArg,
957
+ frequency: Annotated[Literal["daily", "weekly"], _d("daily or weekly.")],
958
+ start_date: Annotated[str, _d("First local date a session may fall on.")],
959
+ start_time: Annotated[str, _d("Local start time of each session, e.g. '09:30'.")],
960
+ duration_minutes: DurationArg,
961
+ weekdays: Annotated[list[Weekday] | None, _d("Weekly only: the days of the week.")] = None,
962
+ interval: Annotated[int, Field(ge=1, description="Every n days or weeks.")] = 1,
963
+ count: Annotated[int | None, Field(ge=1, le=366, description="Number of sessions.")] = None,
964
+ until: Annotated[str | None, _d("Last local date, inclusive (within six months).")] = None,
965
+ time_zone: ZoneArg = None,
966
+ source_acquisition_id: Annotated[
967
+ int | None, _d("Copy settings and people from this session.")
968
+ ] = None,
969
+ people: PeopleArg = None,
970
+ notes: NotesArg = None,
971
+ dry_run: DryRunArg = True,
972
+ idempotency_key: IdempotencyArg = None,
973
+ ) -> dict[str, Any]:
974
+ """Create a finite recurring series of sessions. Call with dry_run=true first and show the user the preview.
975
+
976
+ Sessions that fall in a daylight-saving gap are skipped and listed. Confirm with
977
+ dry_run=false and the preview's idempotency_key: repeating the call cannot create it twice.
978
+ """
979
+ zone = self._zone(time_zone)
980
+ if frequency == "weekly" and not weekdays:
981
+ raise DocketToolError("A weekly series needs weekdays, e.g. ['monday', 'thursday'].")
982
+ rule = {
983
+ "frequency": frequency,
984
+ "local_start_time": _hms(start_time),
985
+ "duration_minutes": duration_minutes,
986
+ "time_zone": zone.key,
987
+ "start_date": parse_date(start_date, "start_date").isoformat(),
988
+ "interval": interval,
989
+ "weekdays": weekdays if frequency == "weekly" else None,
990
+ **self._end_rule(count, until),
991
+ }
992
+ body: dict[str, Any] = {
993
+ "subject_id": subject,
994
+ "instrument_id": self._single_instrument(rig)["id"],
995
+ "project_id": project,
996
+ "rule": rule,
997
+ "operator_instructions": notes or "",
998
+ "people": self._people_body(people),
999
+ "source_acquisition_id": source_acquisition_id,
1000
+ }
1001
+ body = {k: v for k, v in body.items() if v is not None}
1002
+ if dry_run:
1003
+ key = str(uuid.uuid4())
1004
+ preview = self.api.preview("/api/v1/recurring-acquisitions/preview", body)
1005
+ return {
1006
+ "dry_run": True,
1007
+ "series": self._rule_view(rule),
1008
+ **self._preview_view(preview, zone),
1009
+ "idempotency_key": key,
1010
+ "confirm_with": {"dry_run": False, "idempotency_key": key},
1011
+ "next_step": NEXT_STEP,
1012
+ }
1013
+ created = self.api.post(
1014
+ "/api/v1/recurring-acquisitions",
1015
+ body,
1016
+ action="create a series",
1017
+ idempotency_key=idempotency_key or str(uuid.uuid4()),
1018
+ )
1019
+ return {
1020
+ "dry_run": False,
1021
+ "series": self._series_view(created["definition"], zone),
1022
+ "session_ids": created["occurrence_ids"],
1023
+ "skipped_dates": created["skipped"],
1024
+ "warnings": _flat_warnings(created.get("warnings")),
1025
+ }
1026
+
1027
+ def edit_series_from(
1028
+ self,
1029
+ series_id: Annotated[int, _d("Series id (see list_series).")],
1030
+ effective_date: Annotated[str, _d("The change applies from this local date on.")],
1031
+ rig: Annotated[str | None, _d("New rig or instrument.")] = None,
1032
+ project: Annotated[str | None, _d("New project.")] = None,
1033
+ frequency: Annotated[Literal["daily", "weekly"] | None, _d("New frequency.")] = None,
1034
+ weekdays: Annotated[list[Weekday] | None, _d("New weekdays (weekly).")] = None,
1035
+ interval: Annotated[int | None, Field(ge=1, description="New every-n.")] = None,
1036
+ start_time: Annotated[str | None, _d("New local start time, e.g. '10:00'.")] = None,
1037
+ duration_minutes: Annotated[
1038
+ int | None, Field(ge=1, le=1440, description="New length.")
1039
+ ] = None,
1040
+ count: Annotated[
1041
+ int | None, Field(ge=1, le=366, description="New number of sessions from the date on.")
1042
+ ] = None,
1043
+ until: Annotated[str | None, _d("New last local date, inclusive.")] = None,
1044
+ time_zone: Annotated[str | None, _d("New IANA zone for the series.")] = None,
1045
+ notes: NotesArg = None,
1046
+ people: PeopleArg = None,
1047
+ dry_run: DryRunArg = True,
1048
+ expected_version: ExpectedVersionArg = None,
1049
+ idempotency_key: IdempotencyArg = None,
1050
+ ) -> dict[str, Any]:
1051
+ """Change a series from a date on: the old series ends the day before and a new one takes over.
1052
+
1053
+ Sessions that already have runs, or were edited by hand, are kept as they are. Call with
1054
+ dry_run=true first and show the user the preview. Unchanged fields, and the end of the
1055
+ series when neither count nor until is given, carry over. Confirm with dry_run=false, the
1056
+ same arguments, and the preview's expected_version and idempotency_key: if the series
1057
+ changed since the preview the call is refused and you must preview again.
1058
+ """
1059
+ if not dry_run:
1060
+ _need_version(expected_version)
1061
+ zone = self._zone(time_zone)
1062
+ detail = self.api.get(f"/api/v1/recurring-acquisitions/{series_id}", what="the series")
1063
+ d = detail["definition"]
1064
+ if not dry_run and d["version"] != expected_version:
1065
+ raise _changed_since_preview()
1066
+ rule = dict(d["rule"])
1067
+ if time_zone:
1068
+ rule["time_zone"] = zone.key
1069
+ series_zone = get_zone(rule["time_zone"])
1070
+ if frequency:
1071
+ rule["frequency"] = frequency
1072
+ if weekdays is not None:
1073
+ rule["weekdays"] = weekdays
1074
+ if rule["frequency"] == "daily":
1075
+ rule["weekdays"] = None
1076
+ elif not rule.get("weekdays"):
1077
+ raise DocketToolError("A weekly series needs weekdays, e.g. ['monday', 'thursday'].")
1078
+ if interval:
1079
+ rule["interval"] = interval
1080
+ if start_time:
1081
+ rule["local_start_time"] = _hms(start_time)
1082
+ if duration_minutes:
1083
+ rule["duration_minutes"] = duration_minutes
1084
+ rule["start_date"] = parse_date(effective_date, "effective_date").isoformat()
1085
+ if count is not None or until is not None:
1086
+ rule.update(self._end_rule(count, until))
1087
+ else:
1088
+ last = detail["occurrences"].get("last_scheduled_start")
1089
+ keep = (
1090
+ parse_instant(last).astimezone(series_zone).date().isoformat()
1091
+ if last
1092
+ else rule.get("until")
1093
+ )
1094
+ rule.update({"count": None, "until": keep})
1095
+ body: dict[str, Any] = {
1096
+ "expected_version": d["version"],
1097
+ "effective_date": rule["start_date"],
1098
+ "subject_id": d["subject_id"],
1099
+ "instrument_id": self._single_instrument(rig)["id"] if rig else d["instrument_id"],
1100
+ "project_id": project or d["project_id"],
1101
+ "rule": rule,
1102
+ "operator_instructions": notes if notes is not None else d["operator_instructions"],
1103
+ "people": self._people_body(people) if people is not None else d["people"],
1104
+ "type_refs": d["type_refs"],
1105
+ "variables": d["variables"],
1106
+ }
1107
+ path = f"/api/v1/recurring-acquisitions/{series_id}/split"
1108
+ if dry_run:
1109
+ key = str(uuid.uuid4())
1110
+ preview = self.api.preview(f"{path}/preview", body)
1111
+ return {
1112
+ "dry_run": True,
1113
+ "from_date": rule["start_date"],
1114
+ "new_series": self._rule_view(rule),
1115
+ **self._preview_view(preview, series_zone),
1116
+ "replaced_session_ids": preview["replaced"],
1117
+ "kept_sessions": preview["kept"],
1118
+ "expected_version": d["version"],
1119
+ "idempotency_key": key,
1120
+ "confirm_with": {
1121
+ "dry_run": False,
1122
+ "expected_version": d["version"],
1123
+ "idempotency_key": key,
1124
+ },
1125
+ "next_step": NEXT_STEP,
1126
+ }
1127
+ done = self.api.post(
1128
+ path,
1129
+ body,
1130
+ action="edit this series",
1131
+ idempotency_key=idempotency_key or str(uuid.uuid4()),
1132
+ )
1133
+ return {
1134
+ "dry_run": False,
1135
+ "new_series": self._series_view(done["definition"], series_zone),
1136
+ "previous_series": self._series_view(done["previous"], series_zone),
1137
+ "new_session_ids": done["occurrence_ids"],
1138
+ "replaced_session_ids": done["replaced"],
1139
+ "kept_sessions": done["kept"],
1140
+ "skipped_dates": done["skipped"],
1141
+ "warnings": _flat_warnings(done.get("warnings")),
1142
+ }
1143
+
1144
+ def archive_series(
1145
+ self,
1146
+ id: Annotated[int, _d("Series id.")],
1147
+ future_sessions: Annotated[
1148
+ Literal["keep", "archive"],
1149
+ _d("What happens to its sessions that have not happened yet."),
1150
+ ],
1151
+ time_zone: ZoneArg = None,
1152
+ dry_run: DryRunArg = True,
1153
+ expected_version: ExpectedVersionArg = None,
1154
+ ) -> dict[str, Any]:
1155
+ """Archive a series, and either keep or archive its upcoming sessions. Call with dry_run=true first and show the user the preview.
1156
+
1157
+ Confirm with dry_run=false and the preview's expected_version.
1158
+ """
1159
+ zone = self._zone(time_zone)
1160
+ if not dry_run:
1161
+ version = _need_version(expected_version)
1162
+ done = self.api.post(
1163
+ f"/api/v1/recurring-acquisitions/{id}/archive",
1164
+ {"expected_version": version, "future_occurrences": future_sessions},
1165
+ action="archive this series",
1166
+ )
1167
+ return {
1168
+ "dry_run": False,
1169
+ "series": self._series_view(done["definition"], zone),
1170
+ "archived_session_ids": done["archived_occurrence_ids"],
1171
+ }
1172
+ detail = self.api.get(f"/api/v1/recurring-acquisitions/{id}", what="the series")
1173
+ d = detail["definition"]
1174
+ now = datetime.now(UTC)
1175
+ # Sessions kept from an earlier series are attached to this one but carry the old series
1176
+ # id, so also match the definition's own occurrence ids (same subject and instrument).
1177
+ own_ids = set(detail["occurrences"]["ids"])
1178
+ upcoming = [
1179
+ s
1180
+ for s in self._list_sessions(
1181
+ subject_id=d["subject_id"],
1182
+ instrument_id=d["instrument_id"],
1183
+ start=format_utc(now),
1184
+ limit=500,
1185
+ )
1186
+ if (s["id"] in own_ids or s.get("recurring_acquisition_id") == id)
1187
+ and parse_instant(s["scheduled_start"]) > now
1188
+ ]
1189
+ affected = (
1190
+ [s for s in upcoming if not s["has_runs"]] if future_sessions == "archive" else []
1191
+ )
1192
+ return {
1193
+ "dry_run": True,
1194
+ "series": self._series_view(d, zone),
1195
+ "future_sessions": future_sessions,
1196
+ "upcoming_sessions": len(upcoming),
1197
+ "would_archive_sessions": [self._session_view(s, zone) for s in affected],
1198
+ "note": "Estimated from the schedule; the server decides which sessions are untouched.",
1199
+ "expected_version": d["version"],
1200
+ "confirm_with": {"dry_run": False, "expected_version": d["version"]},
1201
+ "next_step": NEXT_STEP,
1202
+ }