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.
- docket/__init__.py +8 -0
- docket/api/__init__.py +1 -0
- docket/api/access/__init__.py +1 -0
- docket/api/access/add_principal.py +193 -0
- docket/api/access/archive_role_assignment.py +182 -0
- docket/api/access/create_api_token.py +198 -0
- docket/api/access/create_application_principal.py +188 -0
- docket/api/access/create_role_assignment.py +194 -0
- docket/api/access/list_api_tokens.py +184 -0
- docket/api/access/list_principals.py +184 -0
- docket/api/access/list_role_assignments.py +200 -0
- docket/api/access/revoke_api_token.py +173 -0
- docket/api/identity/__init__.py +1 -0
- docket/api/identity/create_my_api_token.py +206 -0
- docket/api/identity/get_me.py +127 -0
- docket/api/identity/list_my_api_tokens.py +151 -0
- docket/api/identity/revoke_my_api_token.py +178 -0
- docket/api/recurring_acquisitions/__init__.py +1 -0
- docket/api/recurring_acquisitions/archive_recurring_acquisition.py +207 -0
- docket/api/recurring_acquisitions/create_recurring_acquisition.py +218 -0
- docket/api/recurring_acquisitions/get_recurring_acquisition.py +163 -0
- docket/api/recurring_acquisitions/list_recurring_acquisitions.py +248 -0
- docket/api/recurring_acquisitions/preview_recurring_acquisition.py +187 -0
- docket/api/recurring_acquisitions/preview_split_recurring_acquisition.py +232 -0
- docket/api/recurring_acquisitions/split_recurring_acquisition.py +258 -0
- docket/api/runs/__init__.py +1 -0
- docket/api/runs/get_run.py +160 -0
- docket/api/runs/list_runs.py +180 -0
- docket/api/runs/report_run_outcome.py +206 -0
- docket/api/scheduled_acquisitions/__init__.py +1 -0
- docket/api/scheduled_acquisitions/archive_scheduled_acquisition.py +203 -0
- docket/api/scheduled_acquisitions/create_scheduled_acquisition.py +185 -0
- docket/api/scheduled_acquisitions/get_resolved_settings.py +169 -0
- docket/api/scheduled_acquisitions/get_scheduled_acquisition.py +159 -0
- docket/api/scheduled_acquisitions/list_active_acquisitions.py +297 -0
- docket/api/scheduled_acquisitions/list_runs_for_scheduled_acquisition.py +191 -0
- docket/api/scheduled_acquisitions/list_scheduled_acquisitions.py +332 -0
- docket/api/scheduled_acquisitions/preview_scheduled_acquisition_edit.py +224 -0
- docket/api/scheduled_acquisitions/set_planning_status.py +199 -0
- docket/api/scheduled_acquisitions/start_run.py +221 -0
- docket/api/scheduled_acquisitions/update_scheduled_acquisition.py +226 -0
- docket/api/settings/__init__.py +1 -0
- docket/api/settings/list_behavior_curriculum_discriminator_values.py +188 -0
- docket/api/settings/list_behavior_curriculum_settings.py +199 -0
- docket/api/settings/list_fip_discriminator_values.py +184 -0
- docket/api/settings/list_fip_settings.py +195 -0
- docket/api/settings/list_live_behavior_curriculum_settings.py +233 -0
- docket/api/settings/list_live_fip_settings.py +225 -0
- docket/api/settings/preview_behavior_curriculum_settings.py +191 -0
- docket/api/settings/preview_fip_settings.py +177 -0
- docket/api/settings/push_behavior_curriculum_settings.py +222 -0
- docket/api/settings/push_fip_settings.py +206 -0
- docket/api/templates/__init__.py +1 -0
- docket/api/templates/archive_template.py +203 -0
- docket/api/templates/create_template.py +206 -0
- docket/api/templates/get_template.py +159 -0
- docket/api/templates/list_templates.py +174 -0
- docket/api/templates/update_template.py +234 -0
- docket/api/vocabulary/__init__.py +1 -0
- docket/api/vocabulary/list_instruments.py +165 -0
- docket/api/vocabulary/list_persons.py +132 -0
- docket/api/vocabulary/list_projects.py +132 -0
- docket/api/vocabulary/list_subjects.py +132 -0
- docket/api/vocabulary/lookup_person.py +166 -0
- docket/api/vocabulary/lookup_subject.py +166 -0
- docket/api/vocabulary/sync_vocabulary.py +148 -0
- docket/client.py +272 -0
- docket/errors.py +16 -0
- docket/models/__init__.py +221 -0
- docket/models/access_role.py +12 -0
- docket/models/access_role_nullable_type_1.py +12 -0
- docket/models/access_role_nullable_type_2_type_1.py +12 -0
- docket/models/access_role_nullable_type_3_type_1.py +12 -0
- docket/models/acquisition_variable.py +72 -0
- docket/models/acquisition_warning.py +126 -0
- docket/models/add_principal_request.py +83 -0
- docket/models/api_token_response.py +271 -0
- docket/models/api_token_status.py +10 -0
- docket/models/archive_recurring_acquisition_request.py +77 -0
- docket/models/archive_recurring_acquisition_response.py +83 -0
- docket/models/archive_scheduled_acquisition_request.py +78 -0
- docket/models/archive_template_request.py +66 -0
- docket/models/auth_mode.py +10 -0
- docket/models/behavior_curriculum_push_settings_request.py +85 -0
- docket/models/behavior_curriculum_settings.py +55 -0
- docket/models/behavior_curriculum_settings_preview_response.py +102 -0
- docket/models/behavior_curriculum_settings_preview_response_promoted.py +50 -0
- docket/models/behavior_curriculum_settings_version_response.py +160 -0
- docket/models/behavior_curriculum_settings_version_response_promoted.py +50 -0
- docket/models/caller_kind.py +11 -0
- docket/models/create_api_token_request.py +97 -0
- docket/models/create_application_principal_request.py +65 -0
- docket/models/create_personal_api_token_request.py +167 -0
- docket/models/create_recurring_acquisition_request.py +269 -0
- docket/models/create_role_assignment_request.py +99 -0
- docket/models/create_scheduled_acquisition_request.py +265 -0
- docket/models/create_template_request.py +323 -0
- docket/models/created_api_token_response.py +82 -0
- docket/models/effective_status.py +15 -0
- docket/models/fip_color.py +13 -0
- docket/models/fip_fiber.py +93 -0
- docket/models/fip_mode.py +9 -0
- docket/models/fip_push_settings_request.py +82 -0
- docket/models/fip_settings.py +91 -0
- docket/models/fip_settings_preview_response.py +97 -0
- docket/models/fip_settings_preview_response_promoted.py +50 -0
- docket/models/fip_settings_version_response.py +155 -0
- docket/models/fip_settings_version_response_promoted.py +50 -0
- docket/models/future_occurrences.py +9 -0
- docket/models/get_resolved_settings_response_200.py +65 -0
- docket/models/held_role_response.py +116 -0
- docket/models/http_validation_problem_details.py +204 -0
- docket/models/http_validation_problem_details_errors.py +59 -0
- docket/models/instrument_response.py +91 -0
- docket/models/kept_occurrence.py +85 -0
- docket/models/kept_occurrence_reason.py +10 -0
- docket/models/list_active_acquisitions_effective_status_item.py +15 -0
- docket/models/list_scheduled_acquisitions_planning_status.py +11 -0
- docket/models/me_caller.py +118 -0
- docket/models/me_permission.py +98 -0
- docket/models/me_response.py +158 -0
- docket/models/me_role.py +75 -0
- docket/models/occurrence_preview.py +121 -0
- docket/models/occurrence_summary.py +126 -0
- docket/models/occurrence_warnings.py +91 -0
- docket/models/permission_action.py +12 -0
- docket/models/person_assignment.py +74 -0
- docket/models/person_lookup_response.py +131 -0
- docket/models/person_response.py +103 -0
- docket/models/person_role.py +9 -0
- docket/models/personal_token_limits.py +92 -0
- docket/models/planning_status.py +11 -0
- docket/models/principal_kind.py +9 -0
- docket/models/principal_response.py +216 -0
- docket/models/problem_details.py +177 -0
- docket/models/project_response.py +75 -0
- docket/models/recurrence_exception_kind.py +13 -0
- docket/models/recurrence_exception_response.py +103 -0
- docket/models/recurrence_frequency.py +9 -0
- docket/models/recurrence_preview_response.py +101 -0
- docket/models/recurrence_rule.py +207 -0
- docket/models/recurring_acquisition_created_response.py +123 -0
- docket/models/recurring_acquisition_detail_response.py +105 -0
- docket/models/recurring_acquisition_response.py +326 -0
- docket/models/report_run_outcome_request.py +89 -0
- docket/models/resolution_mode.py +10 -0
- docket/models/resolved_settings.py +73 -0
- docket/models/role_assignment_response.py +157 -0
- docket/models/role_origin.py +10 -0
- docket/models/role_source_kind.py +10 -0
- docket/models/run_origin.py +9 -0
- docket/models/run_outcome.py +9 -0
- docket/models/run_response.py +181 -0
- docket/models/run_settings_snapshot.py +72 -0
- docket/models/run_start_response.py +91 -0
- docket/models/run_start_response_settings.py +65 -0
- docket/models/run_status.py +11 -0
- docket/models/scheduled_acquisition_edit_preview_response.py +150 -0
- docket/models/scheduled_acquisition_response.py +366 -0
- docket/models/set_planning_status_request.py +90 -0
- docket/models/settings_selector.py +138 -0
- docket/models/skipped_occurrence.py +85 -0
- docket/models/split_preview_response.py +129 -0
- docket/models/split_recurring_acquisition_request.py +261 -0
- docket/models/split_recurring_acquisition_response.py +159 -0
- docket/models/start_run_request.py +73 -0
- docket/models/subject_lookup_response.py +149 -0
- docket/models/subject_response.py +128 -0
- docket/models/sync_counts.py +80 -0
- docket/models/sync_response.py +81 -0
- docket/models/template_response.py +302 -0
- docket/models/update_scheduled_acquisition_request.py +235 -0
- docket/models/update_template_request.py +279 -0
- docket/models/weekday.py +14 -0
- docket/types.py +54 -0
- docket_auth/__init__.py +239 -0
- docket_client-0.3.2.dist-info/METADATA +233 -0
- docket_client-0.3.2.dist-info/RECORD +188 -0
- docket_client-0.3.2.dist-info/WHEEL +4 -0
- docket_client-0.3.2.dist-info/entry_points.txt +2 -0
- docket_mcp/__init__.py +6 -0
- docket_mcp/api.py +207 -0
- docket_mcp/config.py +57 -0
- docket_mcp/server.py +78 -0
- docket_mcp/signin.py +155 -0
- docket_mcp/slots.py +80 -0
- docket_mcp/times.py +160 -0
- 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
|
+
}
|