temdb 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- temdb/alembic.ini +29 -0
- temdb/migrations/env.py +66 -0
- temdb/migrations/script.py.mako +28 -0
- temdb/migrations/versions/.gitkeep +0 -0
- temdb/migrations/versions/20260612_0001_initial_schema.py +542 -0
- temdb/migrations/versions/20260717_ec4c96d0bf77_add_description_to_block_table.py +32 -0
- temdb/migrations/versions/20260721_44f75ed44d66_updated_at_nullable_no_server_default.py +165 -0
- temdb/migrations/versions/20260721_bec991e0d0eb_dataset_status_server_default.py +38 -0
- temdb/migrations/versions/20260801_a1d2f3e4b5c6_add_sections_run_parameters.py +67 -0
- temdb/server/__init__.py +3 -0
- temdb/server/api/__init__.py +0 -0
- temdb/server/api/v2/__init__.py +0 -0
- temdb/server/api/v2/acquisition.py +1094 -0
- temdb/server/api/v2/block.py +228 -0
- temdb/server/api/v2/cutting_session.py +229 -0
- temdb/server/api/v2/dataset.py +139 -0
- temdb/server/api/v2/lens_correction.py +121 -0
- temdb/server/api/v2/microscope.py +93 -0
- temdb/server/api/v2/quality_control.py +185 -0
- temdb/server/api/v2/roi.py +372 -0
- temdb/server/api/v2/section.py +457 -0
- temdb/server/api/v2/specimen.py +246 -0
- temdb/server/api/v2/substrate.py +180 -0
- temdb/server/api/v2/tasks.py +508 -0
- temdb/server/cli.py +48 -0
- temdb/server/config.py +46 -0
- temdb/server/database.py +75 -0
- temdb/server/dependencies.py +24 -0
- temdb/server/exception_handlers.py +109 -0
- temdb/server/ids.py +12 -0
- temdb/server/main.py +172 -0
- temdb/server/sqlmodels/__init__.py +29 -0
- temdb/server/sqlmodels/acquisition.py +101 -0
- temdb/server/sqlmodels/base.py +41 -0
- temdb/server/sqlmodels/block.py +18 -0
- temdb/server/sqlmodels/cutting_session.py +27 -0
- temdb/server/sqlmodels/dataset.py +34 -0
- temdb/server/sqlmodels/lens_correction.py +36 -0
- temdb/server/sqlmodels/microscope.py +22 -0
- temdb/server/sqlmodels/roi.py +55 -0
- temdb/server/sqlmodels/section.py +55 -0
- temdb/server/sqlmodels/specimen.py +17 -0
- temdb/server/sqlmodels/substrate.py +23 -0
- temdb/server/sqlmodels/task.py +60 -0
- temdb/server/sqlmodels/tile.py +53 -0
- temdb/server/sqlmodels/tile_partition.py +147 -0
- temdb-3.0.0.dist-info/METADATA +16 -0
- temdb-3.0.0.dist-info/RECORD +51 -0
- temdb-3.0.0.dist-info/WHEEL +4 -0
- temdb-3.0.0.dist-info/entry_points.txt +3 -0
- temdb-3.0.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import (
|
|
7
|
+
APIRouter,
|
|
8
|
+
Body,
|
|
9
|
+
Depends,
|
|
10
|
+
HTTPException,
|
|
11
|
+
Query,
|
|
12
|
+
Response,
|
|
13
|
+
status,
|
|
14
|
+
)
|
|
15
|
+
from sqlalchemy import and_, func, select, update
|
|
16
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from temdb.models import (
|
|
20
|
+
RUN_STATUSES,
|
|
21
|
+
AcquisitionCreate,
|
|
22
|
+
AcquisitionFullMetadata,
|
|
23
|
+
AcquisitionResponse,
|
|
24
|
+
AcquisitionUpdate,
|
|
25
|
+
StorageLocation,
|
|
26
|
+
StorageLocationCreate,
|
|
27
|
+
TileCreate,
|
|
28
|
+
TileResponse,
|
|
29
|
+
)
|
|
30
|
+
from temdb.server.dependencies import get_async_session
|
|
31
|
+
from temdb.server.ids import uuid7
|
|
32
|
+
from temdb.server.sqlmodels import (
|
|
33
|
+
AcquisitionSQLModel,
|
|
34
|
+
AcquisitionTaskSQLModel,
|
|
35
|
+
BlockSQLModel,
|
|
36
|
+
CuttingSessionSQLModel,
|
|
37
|
+
DatasetSQLModel,
|
|
38
|
+
LensCorrectionSQLModel,
|
|
39
|
+
MicroscopeSQLModel,
|
|
40
|
+
ROISQLModel,
|
|
41
|
+
SectionSQLModel,
|
|
42
|
+
SpecimenSQLModel,
|
|
43
|
+
SubstrateSQLModel,
|
|
44
|
+
TileSQLModel,
|
|
45
|
+
)
|
|
46
|
+
from temdb.server.sqlmodels.tile_partition import ensure_tile_partition
|
|
47
|
+
|
|
48
|
+
acquisition_api = APIRouter(
|
|
49
|
+
tags=["Acquisitions"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _status_condition(acq_status: str):
|
|
56
|
+
"""Axis-1 filter; the literal 'in_flight' selects runs without a terminal status."""
|
|
57
|
+
if acq_status == "in_flight":
|
|
58
|
+
return AcquisitionSQLModel.status.is_(None)
|
|
59
|
+
if acq_status in RUN_STATUSES:
|
|
60
|
+
return AcquisitionSQLModel.status == acq_status
|
|
61
|
+
raise HTTPException(422, f"status filter must be 'in_flight' or one of {RUN_STATUSES}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tile_payload(
|
|
65
|
+
tile: TileSQLModel, acquisition_id: str, acquisition_internal_id: int | None = None
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
payload = {
|
|
68
|
+
"tile_id": str(tile.tile_id),
|
|
69
|
+
"dataset_id": str(tile.dataset_id),
|
|
70
|
+
"acquisition_id": acquisition_id,
|
|
71
|
+
"raster_index": tile.raster_index,
|
|
72
|
+
"stage_position": {"x": tile.stage_x_nm, "y": tile.stage_y_nm},
|
|
73
|
+
"raster_position": {"row": tile.montage_row, "col": tile.montage_col},
|
|
74
|
+
"focus_score": tile.focus_score,
|
|
75
|
+
"min_value": tile.min_value,
|
|
76
|
+
"max_value": tile.max_value,
|
|
77
|
+
"mean_value": tile.mean_value,
|
|
78
|
+
"std_value": tile.std_value,
|
|
79
|
+
"image_path": tile.image_path,
|
|
80
|
+
"matcher": tile.matcher,
|
|
81
|
+
"supertile_id": tile.supertile_id,
|
|
82
|
+
"supertile_raster_position": tile.supertile_raster_position,
|
|
83
|
+
"created_at": tile.created_at,
|
|
84
|
+
}
|
|
85
|
+
if acquisition_internal_id is not None:
|
|
86
|
+
payload["acquisition_ref"] = {"id": str(acquisition_internal_id)}
|
|
87
|
+
return payload
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _column_value(value: Any) -> Any:
|
|
91
|
+
if hasattr(value, "model_dump"):
|
|
92
|
+
return value.model_dump(mode="json")
|
|
93
|
+
return value.value if hasattr(value, "value") else value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tile_sql_kwargs(tile_data: TileCreate, dataset_id: uuid.UUID, run_id: uuid.UUID) -> dict[str, Any]:
|
|
97
|
+
"""Translate the dict-shaped wire model into flattened tile columns."""
|
|
98
|
+
return {
|
|
99
|
+
"dataset_id": dataset_id,
|
|
100
|
+
"run_id": run_id,
|
|
101
|
+
"raster_index": tile_data.raster_index,
|
|
102
|
+
"tile_id": tile_data.tile_id if tile_data.tile_id is not None else uuid7(),
|
|
103
|
+
"stage_x_nm": tile_data.stage_position["x"],
|
|
104
|
+
"stage_y_nm": tile_data.stage_position["y"],
|
|
105
|
+
"montage_row": tile_data.raster_position["row"],
|
|
106
|
+
"montage_col": tile_data.raster_position["col"],
|
|
107
|
+
"focus_score": tile_data.focus_score,
|
|
108
|
+
"min_value": tile_data.min_value,
|
|
109
|
+
"max_value": tile_data.max_value,
|
|
110
|
+
"mean_value": tile_data.mean_value,
|
|
111
|
+
"std_value": tile_data.std_value,
|
|
112
|
+
"image_path": str(tile_data.image_path),
|
|
113
|
+
"matcher": [m.model_dump() for m in tile_data.matcher] if tile_data.matcher else None,
|
|
114
|
+
"supertile_id": tile_data.supertile_id,
|
|
115
|
+
"supertile_raster_position": tile_data.supertile_raster_position,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@acquisition_api.get("/acquisitions", response_model=dict[str, Any])
|
|
120
|
+
async def list_acquisitions(
|
|
121
|
+
response: Response,
|
|
122
|
+
cursor: str | None = Query(
|
|
123
|
+
None,
|
|
124
|
+
description="Cursor for pagination (e.g., last seen acquisition_id or _id)",
|
|
125
|
+
),
|
|
126
|
+
limit: int = Query(50, ge=1, le=1000),
|
|
127
|
+
sort_by: str = Query("start_time", description="Field to sort by (e.g., start_time, acquisition_id)"),
|
|
128
|
+
sort_order: int = Query(-1, description="Sort order (-1=desc, 1=asc)"),
|
|
129
|
+
specimen_id: str | None = Query(None, description="Filter by human-readable Specimen ID"),
|
|
130
|
+
roi_id: str | None = Query(None, description="Filter by human-readable ROI ID"),
|
|
131
|
+
acquisition_task_id: str | None = Query(None, description="Filter by human-readable Acquisition Task ID"),
|
|
132
|
+
montage_set_name: str | None = Query(None),
|
|
133
|
+
magnification: int | None = Query(None, ge=1),
|
|
134
|
+
acq_status: str | None = Query(
|
|
135
|
+
None,
|
|
136
|
+
alias="status",
|
|
137
|
+
description="Terminal status (complete, aborted, failed) or the literal 'in_flight'",
|
|
138
|
+
),
|
|
139
|
+
qc_state: str | None = Query(None, description="Filter by QC axis state"),
|
|
140
|
+
kind: str | None = Query(None, description="Filter by run kind (montage, lens_correction)"),
|
|
141
|
+
start_date: datetime | None = Query(None),
|
|
142
|
+
end_date: datetime | None = Query(None),
|
|
143
|
+
param_tile_focus_lt: float | None = Query(
|
|
144
|
+
None,
|
|
145
|
+
description="Filter acquisitions where the avg_focus_score rollup is less than this value",
|
|
146
|
+
),
|
|
147
|
+
param_tile_match_quality_lt: float | None = Query(
|
|
148
|
+
None,
|
|
149
|
+
description="Filter acquisitions where tile match quality is less than this value",
|
|
150
|
+
),
|
|
151
|
+
param_tile_dx_gt: float | None = Query(
|
|
152
|
+
None, description="Filter acquisitions where tile dx is greater than this value"
|
|
153
|
+
),
|
|
154
|
+
param_tile_dy_gt: float | None = Query(
|
|
155
|
+
None, description="Filter acquisitions where tile dy is greater than this value"
|
|
156
|
+
),
|
|
157
|
+
fields: list[str] | None = Query(None, description="Fields to return (e.g., ['acquisition_id', 'status'])"),
|
|
158
|
+
session: AsyncSession = Depends(get_async_session),
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Retrieve a list of acquisitions with filtering, sorting, and pagination.
|
|
161
|
+
|
|
162
|
+
Rollup filters (avg_focus_score) read columns written by ingest/QC services — out of repo.
|
|
163
|
+
"""
|
|
164
|
+
conditions = []
|
|
165
|
+
if specimen_id:
|
|
166
|
+
conditions.append(AcquisitionSQLModel.specimen_id == specimen_id)
|
|
167
|
+
if roi_id:
|
|
168
|
+
conditions.append(AcquisitionSQLModel.roi_id == roi_id)
|
|
169
|
+
if acquisition_task_id:
|
|
170
|
+
conditions.append(AcquisitionSQLModel.acquisition_task_id == acquisition_task_id)
|
|
171
|
+
if montage_set_name:
|
|
172
|
+
conditions.append(AcquisitionSQLModel.montage_set_name == montage_set_name)
|
|
173
|
+
if acq_status:
|
|
174
|
+
conditions.append(_status_condition(acq_status))
|
|
175
|
+
if qc_state:
|
|
176
|
+
conditions.append(AcquisitionSQLModel.qc_state == qc_state)
|
|
177
|
+
if kind:
|
|
178
|
+
conditions.append(AcquisitionSQLModel.kind == kind)
|
|
179
|
+
if start_date:
|
|
180
|
+
conditions.append(AcquisitionSQLModel.start_time >= start_date)
|
|
181
|
+
if end_date:
|
|
182
|
+
conditions.append(AcquisitionSQLModel.start_time <= end_date)
|
|
183
|
+
if param_tile_focus_lt is not None:
|
|
184
|
+
conditions.append(AcquisitionSQLModel.avg_focus_score < param_tile_focus_lt)
|
|
185
|
+
|
|
186
|
+
if param_tile_match_quality_lt is not None:
|
|
187
|
+
logger.warning(
|
|
188
|
+
"API parameter 'param_tile_match_quality_lt' (%s) is accepted but not yet implemented.",
|
|
189
|
+
param_tile_match_quality_lt,
|
|
190
|
+
)
|
|
191
|
+
if param_tile_dx_gt is not None:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"API parameter 'param_tile_dx_gt' (%s) is accepted but not yet implemented.",
|
|
194
|
+
param_tile_dx_gt,
|
|
195
|
+
)
|
|
196
|
+
if param_tile_dy_gt is not None:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"API parameter 'param_tile_dy_gt' (%s) is accepted but not yet implemented.",
|
|
199
|
+
param_tile_dy_gt,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
sort_column = getattr(AcquisitionSQLModel, sort_by, AcquisitionSQLModel.start_time)
|
|
203
|
+
order_column = sort_column.asc() if sort_order == 1 else sort_column.desc()
|
|
204
|
+
rows = (
|
|
205
|
+
await session.execute(
|
|
206
|
+
select(
|
|
207
|
+
AcquisitionSQLModel,
|
|
208
|
+
SpecimenSQLModel.id,
|
|
209
|
+
ROISQLModel.id,
|
|
210
|
+
AcquisitionTaskSQLModel.id,
|
|
211
|
+
)
|
|
212
|
+
.select_from(AcquisitionSQLModel)
|
|
213
|
+
.outerjoin(
|
|
214
|
+
SpecimenSQLModel,
|
|
215
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
216
|
+
)
|
|
217
|
+
.outerjoin(
|
|
218
|
+
ROISQLModel,
|
|
219
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
220
|
+
)
|
|
221
|
+
.outerjoin(
|
|
222
|
+
AcquisitionTaskSQLModel,
|
|
223
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
224
|
+
)
|
|
225
|
+
.where(*conditions)
|
|
226
|
+
.order_by(order_column)
|
|
227
|
+
)
|
|
228
|
+
).all()
|
|
229
|
+
acquisitions = [row[0] for row in rows]
|
|
230
|
+
if magnification is not None:
|
|
231
|
+
rows = [
|
|
232
|
+
row
|
|
233
|
+
for row in rows
|
|
234
|
+
if isinstance(row[0].acquisition_settings, dict)
|
|
235
|
+
and row[0].acquisition_settings.get("magnification") == magnification
|
|
236
|
+
]
|
|
237
|
+
acquisitions = [row[0] for row in rows]
|
|
238
|
+
total_count = len(acquisitions)
|
|
239
|
+
rows = rows[:limit]
|
|
240
|
+
payloads = []
|
|
241
|
+
for acq, _specimen_ref, _roi_ref, _task_ref in rows:
|
|
242
|
+
payload = AcquisitionResponse.model_validate(acq).model_dump(mode="json")
|
|
243
|
+
if fields:
|
|
244
|
+
kept = {field: payload.get(field) for field in fields}
|
|
245
|
+
kept["id"] = payload["id"]
|
|
246
|
+
payload = kept
|
|
247
|
+
payloads.append(payload)
|
|
248
|
+
next_cursor = str(rows[-1][0].id) if rows else None
|
|
249
|
+
metadata = {
|
|
250
|
+
"total_count": total_count,
|
|
251
|
+
"returned_count": len(payloads),
|
|
252
|
+
"limit": limit,
|
|
253
|
+
"sort_by": sort_by,
|
|
254
|
+
"sort_order": sort_order if sort_order in [-1, 1] else -1,
|
|
255
|
+
"next_cursor": next_cursor,
|
|
256
|
+
}
|
|
257
|
+
response.headers["Cache-Control"] = "private, max-age=300"
|
|
258
|
+
response.headers["X-Total-Count"] = str(total_count)
|
|
259
|
+
return {"acquisitions": payloads, "metadata": metadata}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@acquisition_api.post("/acquisitions", response_model=AcquisitionResponse, status_code=status.HTTP_201_CREATED)
|
|
263
|
+
async def create_acquisition(
|
|
264
|
+
acq_data: AcquisitionCreate,
|
|
265
|
+
session: AsyncSession = Depends(get_async_session),
|
|
266
|
+
):
|
|
267
|
+
"""Create a new acquisition with validation but without transactions.
|
|
268
|
+
|
|
269
|
+
Runs start in flight (status NULL, end_time NULL); the terminal status is written
|
|
270
|
+
once via PATCH. Montage runs require ROI/specimen lineage; lens_correction runs
|
|
271
|
+
are exempt and reference their correction via lc_id (never via calibration_info).
|
|
272
|
+
"""
|
|
273
|
+
if acq_data.kind == "montage" and acq_data.roi_id is None:
|
|
274
|
+
raise HTTPException(
|
|
275
|
+
422,
|
|
276
|
+
"roi_id is required for kind='montage' acquisitions; only lens_correction runs may omit lineage.",
|
|
277
|
+
)
|
|
278
|
+
existing = await session.scalars(
|
|
279
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acq_data.acquisition_id)
|
|
280
|
+
)
|
|
281
|
+
if existing.first() is not None:
|
|
282
|
+
raise HTTPException(400, f"Acquisition ID '{acq_data.acquisition_id}' already exists.")
|
|
283
|
+
microscope = await session.scalars(
|
|
284
|
+
select(MicroscopeSQLModel).where(MicroscopeSQLModel.microscope_id == acq_data.microscope_id)
|
|
285
|
+
)
|
|
286
|
+
if microscope.first() is None:
|
|
287
|
+
raise HTTPException(404, f"Microscope '{acq_data.microscope_id}' not found.")
|
|
288
|
+
if acq_data.lc_id is not None:
|
|
289
|
+
lens_correction = await session.scalars(
|
|
290
|
+
select(LensCorrectionSQLModel).where(LensCorrectionSQLModel.lc_id == acq_data.lc_id)
|
|
291
|
+
)
|
|
292
|
+
if lens_correction.first() is None:
|
|
293
|
+
raise HTTPException(404, f"Lens correction '{acq_data.lc_id}' not found.")
|
|
294
|
+
task_row = (
|
|
295
|
+
await session.execute(
|
|
296
|
+
select(AcquisitionTaskSQLModel, SpecimenSQLModel.id)
|
|
297
|
+
.select_from(AcquisitionTaskSQLModel)
|
|
298
|
+
.outerjoin(
|
|
299
|
+
SpecimenSQLModel,
|
|
300
|
+
SpecimenSQLModel.specimen_id == AcquisitionTaskSQLModel.specimen_id,
|
|
301
|
+
)
|
|
302
|
+
.where(AcquisitionTaskSQLModel.task_id == acq_data.acquisition_task_id)
|
|
303
|
+
)
|
|
304
|
+
).first()
|
|
305
|
+
if task_row is None:
|
|
306
|
+
raise HTTPException(404, f"Acquisition Task '{acq_data.acquisition_task_id}' not found.")
|
|
307
|
+
task_obj, _specimen_ref = task_row
|
|
308
|
+
roi_obj = None
|
|
309
|
+
if acq_data.roi_id is not None:
|
|
310
|
+
roi = await session.scalars(select(ROISQLModel).where(ROISQLModel.roi_id == acq_data.roi_id))
|
|
311
|
+
roi_obj = roi.first()
|
|
312
|
+
if roi_obj is None:
|
|
313
|
+
raise HTTPException(404, f"ROI '{acq_data.roi_id}' not found.")
|
|
314
|
+
if task_obj.roi_id != roi_obj.roi_id:
|
|
315
|
+
raise HTTPException(
|
|
316
|
+
400,
|
|
317
|
+
f"ROI ID '{roi_obj.roi_id}' does not match ROI reference in Task '{task_obj.task_id}'.",
|
|
318
|
+
)
|
|
319
|
+
if acq_data.kind == "montage" and task_obj.specimen_id is None:
|
|
320
|
+
raise HTTPException(
|
|
321
|
+
422,
|
|
322
|
+
f"Task '{task_obj.task_id}' has no specimen lineage; kind='montage' acquisitions require it.",
|
|
323
|
+
)
|
|
324
|
+
replacement_id = None
|
|
325
|
+
if acq_data.replaces_acquisition_id:
|
|
326
|
+
replacement = await session.scalars(
|
|
327
|
+
select(AcquisitionSQLModel).where(
|
|
328
|
+
AcquisitionSQLModel.acquisition_id == str(acq_data.replaces_acquisition_id)
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
replacement_obj = replacement.first()
|
|
332
|
+
if replacement_obj is None:
|
|
333
|
+
raise HTTPException(
|
|
334
|
+
404,
|
|
335
|
+
f"Acquisition to replace ID '{acq_data.replaces_acquisition_id}' not found.",
|
|
336
|
+
)
|
|
337
|
+
replacement_id = replacement_obj.acquisition_id
|
|
338
|
+
dataset_uuid = None
|
|
339
|
+
if acq_data.dataset_id is not None:
|
|
340
|
+
try:
|
|
341
|
+
dataset_uuid = uuid.UUID(acq_data.dataset_id)
|
|
342
|
+
except ValueError:
|
|
343
|
+
raise HTTPException(400, f"Invalid dataset_id '{acq_data.dataset_id}'")
|
|
344
|
+
acquisition = AcquisitionSQLModel(
|
|
345
|
+
acquisition_id=acq_data.acquisition_id,
|
|
346
|
+
montage_id=acq_data.montage_id,
|
|
347
|
+
specimen_id=task_obj.specimen_id if roi_obj is not None else None,
|
|
348
|
+
roi_id=roi_obj.roi_id if roi_obj is not None else None,
|
|
349
|
+
acquisition_task_id=task_obj.task_id,
|
|
350
|
+
microscope_id=acq_data.microscope_id,
|
|
351
|
+
dataset_id=dataset_uuid,
|
|
352
|
+
kind=acq_data.kind,
|
|
353
|
+
lc_id=acq_data.lc_id,
|
|
354
|
+
hardware_settings=acq_data.hardware_settings.model_dump(),
|
|
355
|
+
acquisition_settings=acq_data.acquisition_settings.model_dump(),
|
|
356
|
+
calibration_info=acq_data.calibration_info.model_dump() if acq_data.calibration_info else None,
|
|
357
|
+
tilt_angle_deg=acq_data.tilt_angle_deg,
|
|
358
|
+
created_at=acq_data.created_at or datetime.now(timezone.utc),
|
|
359
|
+
start_time=acq_data.start_time or datetime.now(timezone.utc),
|
|
360
|
+
storage_locations=(
|
|
361
|
+
[loc.model_dump(mode="json") for loc in acq_data.storage_locations] if acq_data.storage_locations else None
|
|
362
|
+
),
|
|
363
|
+
montage_set_name=acq_data.montage_set_name,
|
|
364
|
+
sub_region=acq_data.sub_region.model_dump(mode="json") if acq_data.sub_region else None,
|
|
365
|
+
replaces_acquisition_id=replacement_id,
|
|
366
|
+
)
|
|
367
|
+
session.add(acquisition)
|
|
368
|
+
await session.commit()
|
|
369
|
+
await session.refresh(acquisition)
|
|
370
|
+
return acquisition
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@acquisition_api.get("/acquisitions/{acquisition_id}", response_model=AcquisitionResponse)
|
|
374
|
+
async def get_acquisition(
|
|
375
|
+
acquisition_id: str,
|
|
376
|
+
session: AsyncSession = Depends(get_async_session),
|
|
377
|
+
):
|
|
378
|
+
"""Retrieve a specific acquisition by its human-readable ID."""
|
|
379
|
+
row = (
|
|
380
|
+
await session.execute(
|
|
381
|
+
select(
|
|
382
|
+
AcquisitionSQLModel,
|
|
383
|
+
SpecimenSQLModel.id,
|
|
384
|
+
ROISQLModel.id,
|
|
385
|
+
AcquisitionTaskSQLModel.id,
|
|
386
|
+
)
|
|
387
|
+
.select_from(AcquisitionSQLModel)
|
|
388
|
+
.outerjoin(
|
|
389
|
+
SpecimenSQLModel,
|
|
390
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
391
|
+
)
|
|
392
|
+
.outerjoin(
|
|
393
|
+
ROISQLModel,
|
|
394
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
395
|
+
)
|
|
396
|
+
.outerjoin(
|
|
397
|
+
AcquisitionTaskSQLModel,
|
|
398
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
399
|
+
)
|
|
400
|
+
.where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
401
|
+
)
|
|
402
|
+
).first()
|
|
403
|
+
if row is None:
|
|
404
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
405
|
+
acq_obj, _specimen_ref, _roi_ref, _task_ref = row
|
|
406
|
+
return acq_obj
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@acquisition_api.patch("/acquisitions/{acquisition_id}", response_model=AcquisitionResponse)
|
|
410
|
+
async def update_acquisition(
|
|
411
|
+
acquisition_id: str,
|
|
412
|
+
updated_fields: AcquisitionUpdate = Body(...),
|
|
413
|
+
session: AsyncSession = Depends(get_async_session),
|
|
414
|
+
):
|
|
415
|
+
"""Update details of a specific acquisition."""
|
|
416
|
+
row = (
|
|
417
|
+
await session.execute(
|
|
418
|
+
select(
|
|
419
|
+
AcquisitionSQLModel,
|
|
420
|
+
SpecimenSQLModel.id,
|
|
421
|
+
ROISQLModel.id,
|
|
422
|
+
AcquisitionTaskSQLModel.id,
|
|
423
|
+
)
|
|
424
|
+
.select_from(AcquisitionSQLModel)
|
|
425
|
+
.outerjoin(
|
|
426
|
+
SpecimenSQLModel,
|
|
427
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
428
|
+
)
|
|
429
|
+
.outerjoin(
|
|
430
|
+
ROISQLModel,
|
|
431
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
432
|
+
)
|
|
433
|
+
.outerjoin(
|
|
434
|
+
AcquisitionTaskSQLModel,
|
|
435
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
436
|
+
)
|
|
437
|
+
.where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
438
|
+
)
|
|
439
|
+
).first()
|
|
440
|
+
if row is None:
|
|
441
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
442
|
+
acq_obj, _specimen_ref, _roi_ref, _task_ref = row
|
|
443
|
+
update_data = updated_fields.model_dump(exclude_unset=True)
|
|
444
|
+
if not update_data:
|
|
445
|
+
raise HTTPException(400, "No update data provided")
|
|
446
|
+
updated_by = update_data.pop("updated_by", None)
|
|
447
|
+
now = datetime.now(timezone.utc)
|
|
448
|
+
if "end_time" in update_data and "status" not in update_data:
|
|
449
|
+
raise HTTPException(422, "end_time can only be set together with a terminal status")
|
|
450
|
+
if "qc_state" in update_data:
|
|
451
|
+
update_data["qc_state_updated_at"] = now
|
|
452
|
+
update_data["qc_state_updated_by"] = updated_by
|
|
453
|
+
if "transfer_state" in update_data:
|
|
454
|
+
update_data["transfer_state_updated_at"] = now
|
|
455
|
+
update_data["transfer_state_updated_by"] = updated_by
|
|
456
|
+
if "status" in update_data:
|
|
457
|
+
# Terminal status is write-once: compare-and-swap on status IS NULL so a
|
|
458
|
+
# concurrent terminal PATCH cannot rewrite the first writer's result.
|
|
459
|
+
if update_data.get("end_time") is None:
|
|
460
|
+
update_data["end_time"] = now
|
|
461
|
+
values = {
|
|
462
|
+
field: _column_value(value) for field, value in update_data.items() if hasattr(AcquisitionSQLModel, field)
|
|
463
|
+
}
|
|
464
|
+
result = await session.execute(
|
|
465
|
+
update(AcquisitionSQLModel)
|
|
466
|
+
.where(
|
|
467
|
+
AcquisitionSQLModel.acquisition_id == acquisition_id,
|
|
468
|
+
AcquisitionSQLModel.status.is_(None),
|
|
469
|
+
)
|
|
470
|
+
.values(**values)
|
|
471
|
+
)
|
|
472
|
+
if result.rowcount == 0:
|
|
473
|
+
await session.rollback()
|
|
474
|
+
current = (
|
|
475
|
+
await session.execute(
|
|
476
|
+
select(AcquisitionSQLModel.status).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
477
|
+
)
|
|
478
|
+
).first()
|
|
479
|
+
if current is None:
|
|
480
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
481
|
+
raise HTTPException(
|
|
482
|
+
409,
|
|
483
|
+
f"Acquisition '{acquisition_id}' already has terminal status '{current[0]}'.",
|
|
484
|
+
)
|
|
485
|
+
await session.commit()
|
|
486
|
+
await session.refresh(acq_obj)
|
|
487
|
+
return acq_obj
|
|
488
|
+
for field, value in update_data.items():
|
|
489
|
+
if hasattr(acq_obj, field):
|
|
490
|
+
setattr(acq_obj, field, _column_value(value))
|
|
491
|
+
session.add(acq_obj)
|
|
492
|
+
await session.commit()
|
|
493
|
+
await session.refresh(acq_obj)
|
|
494
|
+
return acq_obj
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@acquisition_api.delete("/acquisitions/{acquisition_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
498
|
+
async def delete_acquisition(
|
|
499
|
+
acquisition_id: str,
|
|
500
|
+
session: AsyncSession = Depends(get_async_session),
|
|
501
|
+
):
|
|
502
|
+
"""Delete a specific acquisition."""
|
|
503
|
+
acquisition = await session.scalars(
|
|
504
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
505
|
+
)
|
|
506
|
+
acq_obj = acquisition.first()
|
|
507
|
+
if acq_obj is None:
|
|
508
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
509
|
+
tile_count = 0
|
|
510
|
+
if acq_obj.dataset_id is not None:
|
|
511
|
+
tile_count = (
|
|
512
|
+
await session.scalars(
|
|
513
|
+
select(func.count())
|
|
514
|
+
.select_from(TileSQLModel)
|
|
515
|
+
.where(
|
|
516
|
+
TileSQLModel.dataset_id == acq_obj.dataset_id,
|
|
517
|
+
TileSQLModel.run_id == acq_obj.run_id,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
).one()
|
|
521
|
+
if tile_count > 0:
|
|
522
|
+
raise HTTPException(
|
|
523
|
+
400,
|
|
524
|
+
f"Cannot delete acquisition '{acquisition_id}': {tile_count} tiles exist.",
|
|
525
|
+
)
|
|
526
|
+
await session.delete(acq_obj)
|
|
527
|
+
await session.commit()
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _ensure_leaf_dataset(session: AsyncSession, dataset_id: uuid.UUID) -> None:
|
|
532
|
+
has_children = (
|
|
533
|
+
await session.scalars(
|
|
534
|
+
select(DatasetSQLModel.dataset_id).where(DatasetSQLModel.parent_dataset_id == dataset_id).limit(1)
|
|
535
|
+
)
|
|
536
|
+
).first()
|
|
537
|
+
if has_children is not None:
|
|
538
|
+
raise HTTPException(status_code=400, detail="parent datasets hold no tiles")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@acquisition_api.post(
|
|
542
|
+
"/acquisitions/{acquisition_id}/tiles",
|
|
543
|
+
response_model=dict[str, Any],
|
|
544
|
+
status_code=status.HTTP_201_CREATED,
|
|
545
|
+
)
|
|
546
|
+
async def add_tile_to_acquisition(
|
|
547
|
+
acquisition_id: str,
|
|
548
|
+
tile_data: TileCreate,
|
|
549
|
+
session: AsyncSession = Depends(get_async_session),
|
|
550
|
+
):
|
|
551
|
+
"""Add a single tile to an acquisition."""
|
|
552
|
+
acquisition = await session.scalars(
|
|
553
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
554
|
+
)
|
|
555
|
+
acq_obj = acquisition.first()
|
|
556
|
+
if acq_obj is None:
|
|
557
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
558
|
+
if acq_obj.dataset_id is None:
|
|
559
|
+
raise HTTPException(
|
|
560
|
+
status_code=409, detail=f"Acquisition '{acquisition_id}' has no dataset_id; cannot store tiles"
|
|
561
|
+
)
|
|
562
|
+
await _ensure_leaf_dataset(session, acq_obj.dataset_id)
|
|
563
|
+
await ensure_tile_partition(session, acq_obj.dataset_id)
|
|
564
|
+
stmt = (
|
|
565
|
+
pg_insert(TileSQLModel)
|
|
566
|
+
.values(_tile_sql_kwargs(tile_data, acq_obj.dataset_id, acq_obj.run_id))
|
|
567
|
+
.on_conflict_do_nothing(index_elements=["dataset_id", "run_id", "raster_index"])
|
|
568
|
+
.returning(TileSQLModel)
|
|
569
|
+
)
|
|
570
|
+
tile = (await session.execute(stmt)).scalars().first()
|
|
571
|
+
if tile is None:
|
|
572
|
+
raise HTTPException(
|
|
573
|
+
409,
|
|
574
|
+
f"Tile with raster_index {tile_data.raster_index} already exists for acquisition '{acquisition_id}'",
|
|
575
|
+
)
|
|
576
|
+
payload = _tile_payload(tile, acq_obj.acquisition_id, acq_obj.id)
|
|
577
|
+
await session.commit()
|
|
578
|
+
return payload
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@acquisition_api.post("/acquisitions/{acquisition_id}/tiles/bulk", response_model=dict)
|
|
582
|
+
async def add_tiles_to_acquisition(
|
|
583
|
+
acquisition_id: str,
|
|
584
|
+
tiles: list[TileCreate],
|
|
585
|
+
session: AsyncSession = Depends(get_async_session),
|
|
586
|
+
):
|
|
587
|
+
"""Add multiple tiles to an acquisition."""
|
|
588
|
+
row = (
|
|
589
|
+
await session.execute(
|
|
590
|
+
select(
|
|
591
|
+
AcquisitionSQLModel,
|
|
592
|
+
SpecimenSQLModel.id,
|
|
593
|
+
ROISQLModel.id,
|
|
594
|
+
AcquisitionTaskSQLModel.id,
|
|
595
|
+
)
|
|
596
|
+
.select_from(AcquisitionSQLModel)
|
|
597
|
+
.outerjoin(
|
|
598
|
+
SpecimenSQLModel,
|
|
599
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
600
|
+
)
|
|
601
|
+
.outerjoin(
|
|
602
|
+
ROISQLModel,
|
|
603
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
604
|
+
)
|
|
605
|
+
.outerjoin(
|
|
606
|
+
AcquisitionTaskSQLModel,
|
|
607
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
608
|
+
)
|
|
609
|
+
.where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
610
|
+
)
|
|
611
|
+
).first()
|
|
612
|
+
if row is None:
|
|
613
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
614
|
+
acq_obj, specimen_ref, roi_ref, task_ref = row
|
|
615
|
+
if acq_obj.dataset_id is None:
|
|
616
|
+
raise HTTPException(409, f"Acquisition '{acquisition_id}' has no dataset_id; cannot store tiles")
|
|
617
|
+
await _ensure_leaf_dataset(session, acq_obj.dataset_id)
|
|
618
|
+
await ensure_tile_partition(session, acq_obj.dataset_id)
|
|
619
|
+
total_tiles = len(tiles)
|
|
620
|
+
inserted = 0
|
|
621
|
+
if tiles:
|
|
622
|
+
# Retried batches are expected (at-least-once ingest): ON CONFLICT DO
|
|
623
|
+
# NOTHING keeps the write-once rows and reports duplicates as skipped.
|
|
624
|
+
rows = [_tile_sql_kwargs(tile, acq_obj.dataset_id, acq_obj.run_id) for tile in tiles]
|
|
625
|
+
result = await session.execute(
|
|
626
|
+
pg_insert(TileSQLModel)
|
|
627
|
+
.values(rows)
|
|
628
|
+
.on_conflict_do_nothing(index_elements=["dataset_id", "run_id", "raster_index"])
|
|
629
|
+
)
|
|
630
|
+
await session.commit()
|
|
631
|
+
inserted = result.rowcount
|
|
632
|
+
return {
|
|
633
|
+
"acquisition_id": acquisition_id,
|
|
634
|
+
"total_received": total_tiles,
|
|
635
|
+
"inserted": inserted,
|
|
636
|
+
"skipped_existing": total_tiles - inserted,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@acquisition_api.get("/acquisitions/{acquisition_id}/tiles", response_model=dict[str, Any])
|
|
641
|
+
async def get_tiles_from_acquisition(
|
|
642
|
+
response: Response,
|
|
643
|
+
acquisition_id: str,
|
|
644
|
+
cursor: int | None = Query(None, description="Last raster_index seen"),
|
|
645
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
646
|
+
fields: list[str] | None = Query(None),
|
|
647
|
+
session: AsyncSession = Depends(get_async_session),
|
|
648
|
+
):
|
|
649
|
+
"""Retrieve tiles associated with a specific acquisition."""
|
|
650
|
+
acquisition = await session.scalars(
|
|
651
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
652
|
+
)
|
|
653
|
+
acq_obj = acquisition.first()
|
|
654
|
+
if acq_obj is None:
|
|
655
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
656
|
+
if acq_obj.dataset_id is None:
|
|
657
|
+
rows = []
|
|
658
|
+
else:
|
|
659
|
+
query = select(TileSQLModel).where(
|
|
660
|
+
TileSQLModel.dataset_id == acq_obj.dataset_id,
|
|
661
|
+
TileSQLModel.run_id == acq_obj.run_id,
|
|
662
|
+
)
|
|
663
|
+
if cursor is not None:
|
|
664
|
+
query = query.where(TileSQLModel.raster_index > cursor)
|
|
665
|
+
query = query.order_by(TileSQLModel.raster_index).limit(limit + 1)
|
|
666
|
+
rows = (await session.scalars(query)).all()
|
|
667
|
+
has_more = len(rows) > limit
|
|
668
|
+
tiles = rows[:limit]
|
|
669
|
+
payloads = [_tile_payload(tile, acq_obj.acquisition_id, acq_obj.id) for tile in tiles]
|
|
670
|
+
if fields:
|
|
671
|
+
trimmed = []
|
|
672
|
+
for payload in payloads:
|
|
673
|
+
entry = {field: payload.get(field) for field in fields}
|
|
674
|
+
entry["raster_index"] = payload.get("raster_index")
|
|
675
|
+
trimmed.append(entry)
|
|
676
|
+
payloads = trimmed
|
|
677
|
+
next_cursor = tiles[-1].raster_index if tiles else None
|
|
678
|
+
response.headers["Cache-Control"] = "private, max-age=300"
|
|
679
|
+
return {
|
|
680
|
+
"tiles": payloads,
|
|
681
|
+
"metadata": {
|
|
682
|
+
"returned_count": len(payloads),
|
|
683
|
+
"limit": limit,
|
|
684
|
+
"current_cursor": cursor,
|
|
685
|
+
"next_cursor": next_cursor,
|
|
686
|
+
"has_more": has_more,
|
|
687
|
+
},
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _tile_uuid_or_404(tile_id: str) -> uuid.UUID:
|
|
692
|
+
"""Parse a tile_id path parameter to a UUID; a malformed id cannot exist."""
|
|
693
|
+
try:
|
|
694
|
+
return uuid.UUID(tile_id)
|
|
695
|
+
except ValueError:
|
|
696
|
+
raise HTTPException(404, f"Tile ID '{tile_id}' not found")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@acquisition_api.get("/acquisitions/{acquisition_id}/tiles/{tile_id}", response_model=TileResponse)
|
|
700
|
+
async def get_tile_from_acquisition(
|
|
701
|
+
acquisition_id: str,
|
|
702
|
+
tile_id: str,
|
|
703
|
+
session: AsyncSession = Depends(get_async_session),
|
|
704
|
+
):
|
|
705
|
+
"""Retrieve a specific tile by its tile_id (UUID), verifying acquisition parent."""
|
|
706
|
+
acquisition = await session.scalars(
|
|
707
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
708
|
+
)
|
|
709
|
+
acq_obj = acquisition.first()
|
|
710
|
+
if acq_obj is None:
|
|
711
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
712
|
+
tile_key = _tile_uuid_or_404(tile_id)
|
|
713
|
+
tile = await session.scalars(
|
|
714
|
+
select(TileSQLModel).where(
|
|
715
|
+
TileSQLModel.tile_id == tile_key,
|
|
716
|
+
TileSQLModel.dataset_id == acq_obj.dataset_id,
|
|
717
|
+
TileSQLModel.run_id == acq_obj.run_id,
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
tile_obj = tile.first()
|
|
721
|
+
if tile_obj is None:
|
|
722
|
+
raise HTTPException(404, f"Tile ID '{tile_id}' not found in acquisition '{acquisition_id}'")
|
|
723
|
+
return _tile_payload(tile_obj, acq_obj.acquisition_id, acq_obj.id)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _current_storage_location(storage_locations: list[dict[str, Any]] | None) -> dict[str, Any] | None:
|
|
727
|
+
if not storage_locations:
|
|
728
|
+
return None
|
|
729
|
+
return next((loc for loc in storage_locations if loc.get("is_current")), None)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
@acquisition_api.post("/acquisitions/{acquisition_id}/storage-locations", response_model=AcquisitionResponse)
|
|
733
|
+
async def add_storage_location(
|
|
734
|
+
acquisition_id: str,
|
|
735
|
+
storage_location: StorageLocationCreate,
|
|
736
|
+
session: AsyncSession = Depends(get_async_session),
|
|
737
|
+
):
|
|
738
|
+
"""Add a storage location entry to an acquisition."""
|
|
739
|
+
row = (
|
|
740
|
+
await session.execute(
|
|
741
|
+
select(
|
|
742
|
+
AcquisitionSQLModel,
|
|
743
|
+
SpecimenSQLModel.id,
|
|
744
|
+
ROISQLModel.id,
|
|
745
|
+
AcquisitionTaskSQLModel.id,
|
|
746
|
+
)
|
|
747
|
+
.select_from(AcquisitionSQLModel)
|
|
748
|
+
.outerjoin(
|
|
749
|
+
SpecimenSQLModel,
|
|
750
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
751
|
+
)
|
|
752
|
+
.outerjoin(
|
|
753
|
+
ROISQLModel,
|
|
754
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
755
|
+
)
|
|
756
|
+
.outerjoin(
|
|
757
|
+
AcquisitionTaskSQLModel,
|
|
758
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
759
|
+
)
|
|
760
|
+
.where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
761
|
+
)
|
|
762
|
+
).first()
|
|
763
|
+
if row is None:
|
|
764
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
765
|
+
acq_obj, _specimen_ref, _roi_ref, _task_ref = row
|
|
766
|
+
|
|
767
|
+
new_location = storage_location.model_dump(mode="json")
|
|
768
|
+
make_current = new_location.get("is_current", True)
|
|
769
|
+
new_location["is_current"] = make_current
|
|
770
|
+
if not new_location.get("date_added"):
|
|
771
|
+
new_location["date_added"] = datetime.now(timezone.utc).isoformat()
|
|
772
|
+
|
|
773
|
+
existing_locations = list(acq_obj.storage_locations or [])
|
|
774
|
+
if make_current:
|
|
775
|
+
for loc in existing_locations:
|
|
776
|
+
if isinstance(loc, dict):
|
|
777
|
+
loc["is_current"] = False
|
|
778
|
+
existing_locations.append(new_location)
|
|
779
|
+
|
|
780
|
+
acq_obj.storage_locations = existing_locations
|
|
781
|
+
session.add(acq_obj)
|
|
782
|
+
await session.commit()
|
|
783
|
+
await session.refresh(acq_obj)
|
|
784
|
+
return acq_obj
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
@acquisition_api.get(
|
|
788
|
+
"/acquisitions/{acquisition_id}/current-storage",
|
|
789
|
+
response_model=StorageLocation | None,
|
|
790
|
+
)
|
|
791
|
+
async def get_current_storage_location(
|
|
792
|
+
acquisition_id: str,
|
|
793
|
+
session: AsyncSession = Depends(get_async_session),
|
|
794
|
+
):
|
|
795
|
+
"""Get the current storage location for an acquisition."""
|
|
796
|
+
acquisition = await session.scalars(
|
|
797
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
798
|
+
)
|
|
799
|
+
acq_obj = acquisition.first()
|
|
800
|
+
if acq_obj is None:
|
|
801
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
802
|
+
return _current_storage_location(acq_obj.storage_locations)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@acquisition_api.get(
|
|
806
|
+
"/acquisitions/{acquisition_id}/minimap-uri",
|
|
807
|
+
response_model=dict[str, str | None],
|
|
808
|
+
)
|
|
809
|
+
async def get_minimap_uri(
|
|
810
|
+
acquisition_id: str,
|
|
811
|
+
session: AsyncSession = Depends(get_async_session),
|
|
812
|
+
):
|
|
813
|
+
"""Get the calculated URI for the acquisition's minimap image."""
|
|
814
|
+
acquisition = await session.scalars(
|
|
815
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
816
|
+
)
|
|
817
|
+
acq_obj = acquisition.first()
|
|
818
|
+
if acq_obj is None:
|
|
819
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
820
|
+
current_location = _current_storage_location(acq_obj.storage_locations)
|
|
821
|
+
base_path = current_location.get("base_path") if current_location else None
|
|
822
|
+
return {"minimap_uri": f"{base_path}/minimap.png" if base_path else None}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@acquisition_api.get("/acquisitions/{acquisition_id}/tile-count", response_model=dict[str, int])
|
|
826
|
+
async def get_tile_count(
|
|
827
|
+
acquisition_id: str,
|
|
828
|
+
session: AsyncSession = Depends(get_async_session),
|
|
829
|
+
):
|
|
830
|
+
"""Get the total count of tiles associated with an acquisition."""
|
|
831
|
+
acquisition = await session.scalars(
|
|
832
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
833
|
+
)
|
|
834
|
+
acq_obj = acquisition.first()
|
|
835
|
+
if acq_obj is None:
|
|
836
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
837
|
+
if acq_obj.dataset_id is None:
|
|
838
|
+
return {"tile_count": 0}
|
|
839
|
+
tile_count = (
|
|
840
|
+
await session.scalars(
|
|
841
|
+
select(func.count())
|
|
842
|
+
.select_from(TileSQLModel)
|
|
843
|
+
.where(
|
|
844
|
+
TileSQLModel.dataset_id == acq_obj.dataset_id,
|
|
845
|
+
TileSQLModel.run_id == acq_obj.run_id,
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
).one()
|
|
849
|
+
return {"tile_count": tile_count}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@acquisition_api.delete(
|
|
853
|
+
"/acquisitions/{acquisition_id}/tiles/{tile_id}",
|
|
854
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
855
|
+
)
|
|
856
|
+
async def delete_tile_from_acquisition(
|
|
857
|
+
acquisition_id: str,
|
|
858
|
+
tile_id: str,
|
|
859
|
+
session: AsyncSession = Depends(get_async_session),
|
|
860
|
+
):
|
|
861
|
+
"""Delete a specific tile, ensuring it belongs to the specified acquisition."""
|
|
862
|
+
acquisition = await session.scalars(
|
|
863
|
+
select(AcquisitionSQLModel).where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
864
|
+
)
|
|
865
|
+
acq_obj = acquisition.first()
|
|
866
|
+
if acq_obj is None:
|
|
867
|
+
raise HTTPException(404, f"Acquisition ID '{acquisition_id}' not found")
|
|
868
|
+
tile_key = _tile_uuid_or_404(tile_id)
|
|
869
|
+
tile = await session.scalars(
|
|
870
|
+
select(TileSQLModel).where(
|
|
871
|
+
TileSQLModel.tile_id == tile_key,
|
|
872
|
+
TileSQLModel.dataset_id == acq_obj.dataset_id,
|
|
873
|
+
TileSQLModel.run_id == acq_obj.run_id,
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
tile_obj = tile.first()
|
|
877
|
+
if tile_obj is None:
|
|
878
|
+
raise HTTPException(404, f"Tile ID '{tile_id}' not found in acquisition '{acquisition_id}'")
|
|
879
|
+
await session.delete(tile_obj)
|
|
880
|
+
await session.commit()
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@acquisition_api.get("/acquisitions/{acquisition_id}/metadata", response_model=AcquisitionFullMetadata)
|
|
885
|
+
async def get_acquisition_with_full_metadata(
|
|
886
|
+
acquisition_id: str,
|
|
887
|
+
session: AsyncSession = Depends(get_async_session),
|
|
888
|
+
):
|
|
889
|
+
"""Retrieve an acquisition with its complete metadata."""
|
|
890
|
+
row = (
|
|
891
|
+
await session.execute(
|
|
892
|
+
select(
|
|
893
|
+
AcquisitionSQLModel,
|
|
894
|
+
AcquisitionTaskSQLModel,
|
|
895
|
+
ROISQLModel,
|
|
896
|
+
SectionSQLModel,
|
|
897
|
+
CuttingSessionSQLModel,
|
|
898
|
+
BlockSQLModel,
|
|
899
|
+
SpecimenSQLModel,
|
|
900
|
+
SubstrateSQLModel,
|
|
901
|
+
SpecimenSQLModel.id,
|
|
902
|
+
ROISQLModel.id,
|
|
903
|
+
AcquisitionTaskSQLModel.id,
|
|
904
|
+
)
|
|
905
|
+
.select_from(AcquisitionSQLModel)
|
|
906
|
+
.outerjoin(
|
|
907
|
+
AcquisitionTaskSQLModel,
|
|
908
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
909
|
+
)
|
|
910
|
+
.outerjoin(
|
|
911
|
+
ROISQLModel,
|
|
912
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
913
|
+
)
|
|
914
|
+
.outerjoin(
|
|
915
|
+
SectionSQLModel,
|
|
916
|
+
SectionSQLModel.section_id == ROISQLModel.section_id,
|
|
917
|
+
)
|
|
918
|
+
.outerjoin(
|
|
919
|
+
CuttingSessionSQLModel,
|
|
920
|
+
CuttingSessionSQLModel.cutting_session_id == SectionSQLModel.cutting_session_id,
|
|
921
|
+
)
|
|
922
|
+
.outerjoin(
|
|
923
|
+
BlockSQLModel,
|
|
924
|
+
and_(
|
|
925
|
+
BlockSQLModel.block_id == CuttingSessionSQLModel.block_id,
|
|
926
|
+
BlockSQLModel.specimen_id == CuttingSessionSQLModel.specimen_id,
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
.outerjoin(
|
|
930
|
+
SpecimenSQLModel,
|
|
931
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
932
|
+
)
|
|
933
|
+
.outerjoin(
|
|
934
|
+
SubstrateSQLModel,
|
|
935
|
+
SubstrateSQLModel.media_id == ROISQLModel.substrate_media_id,
|
|
936
|
+
)
|
|
937
|
+
.where(AcquisitionSQLModel.acquisition_id == acquisition_id)
|
|
938
|
+
)
|
|
939
|
+
).first()
|
|
940
|
+
if row is None:
|
|
941
|
+
raise HTTPException(status_code=404, detail=f"Acquisition ID '{acquisition_id}' not found")
|
|
942
|
+
(
|
|
943
|
+
acq_obj,
|
|
944
|
+
task_obj,
|
|
945
|
+
roi_obj,
|
|
946
|
+
section_obj,
|
|
947
|
+
cutting_session_obj,
|
|
948
|
+
block_obj,
|
|
949
|
+
specimen_obj,
|
|
950
|
+
substrate_obj,
|
|
951
|
+
_specimen_ref,
|
|
952
|
+
_roi_ref,
|
|
953
|
+
_task_ref,
|
|
954
|
+
) = row
|
|
955
|
+
return AcquisitionFullMetadata(
|
|
956
|
+
acquisition=AcquisitionResponse.model_validate(acq_obj),
|
|
957
|
+
task=task_obj.model_dump() if task_obj else None,
|
|
958
|
+
roi=roi_obj.model_dump() if roi_obj else None,
|
|
959
|
+
section=section_obj.model_dump() if section_obj else None,
|
|
960
|
+
cutting_session=cutting_session_obj.model_dump() if cutting_session_obj else None,
|
|
961
|
+
block=block_obj.model_dump() if block_obj else None,
|
|
962
|
+
specimen=specimen_obj.model_dump() if specimen_obj else None,
|
|
963
|
+
substrate=substrate_obj.model_dump() if substrate_obj else None,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@acquisition_api.get("/aggregated/acquisitions", response_model=dict[str, Any])
|
|
968
|
+
async def list_acquisitions_with_hierarchy(
|
|
969
|
+
response: Response,
|
|
970
|
+
cursor: str | None = Query(
|
|
971
|
+
None,
|
|
972
|
+
description="Cursor for pagination (e.g., last seen acquisition_id or _id)",
|
|
973
|
+
),
|
|
974
|
+
limit: int = Query(50, ge=1, le=100),
|
|
975
|
+
sort_by: str = Query("start_time", description="Field to sort by (e.g., start_time, acquisition_id)"),
|
|
976
|
+
sort_order: int = Query(-1, description="Sort order (-1=desc, 1=asc)"),
|
|
977
|
+
specimen_id: str | None = Query(None, description="Filter by human-readable Specimen ID"),
|
|
978
|
+
roi_id: str | None = Query(None, description="Filter by hierarchical ROI ID"),
|
|
979
|
+
acquisition_task_id: str | None = Query(None, description="Filter by human-readable Acquisition Task ID"),
|
|
980
|
+
substrate_media_id: str | None = Query(None, description="Filter by substrate media ID"),
|
|
981
|
+
acq_status: str | None = Query(
|
|
982
|
+
None,
|
|
983
|
+
alias="status",
|
|
984
|
+
description="Terminal status (complete, aborted, failed) or the literal 'in_flight'",
|
|
985
|
+
),
|
|
986
|
+
session: AsyncSession = Depends(get_async_session),
|
|
987
|
+
) -> dict[str, Any]:
|
|
988
|
+
"""Retrieve acquisitions with complete hierarchy metadata."""
|
|
989
|
+
conditions = []
|
|
990
|
+
if specimen_id:
|
|
991
|
+
conditions.append(AcquisitionSQLModel.specimen_id == specimen_id)
|
|
992
|
+
if roi_id:
|
|
993
|
+
conditions.append(AcquisitionSQLModel.roi_id == roi_id)
|
|
994
|
+
if acquisition_task_id:
|
|
995
|
+
conditions.append(AcquisitionSQLModel.acquisition_task_id == acquisition_task_id)
|
|
996
|
+
if acq_status:
|
|
997
|
+
conditions.append(_status_condition(acq_status))
|
|
998
|
+
if substrate_media_id:
|
|
999
|
+
conditions.append(SubstrateSQLModel.media_id == substrate_media_id)
|
|
1000
|
+
|
|
1001
|
+
sort_column = getattr(AcquisitionSQLModel, sort_by, AcquisitionSQLModel.start_time)
|
|
1002
|
+
order_column = sort_column.asc() if sort_order == 1 else sort_column.desc()
|
|
1003
|
+
rows = (
|
|
1004
|
+
await session.execute(
|
|
1005
|
+
select(
|
|
1006
|
+
AcquisitionSQLModel,
|
|
1007
|
+
AcquisitionTaskSQLModel,
|
|
1008
|
+
ROISQLModel,
|
|
1009
|
+
SectionSQLModel,
|
|
1010
|
+
SubstrateSQLModel,
|
|
1011
|
+
CuttingSessionSQLModel,
|
|
1012
|
+
BlockSQLModel,
|
|
1013
|
+
SpecimenSQLModel,
|
|
1014
|
+
SpecimenSQLModel.id,
|
|
1015
|
+
ROISQLModel.id,
|
|
1016
|
+
AcquisitionTaskSQLModel.id,
|
|
1017
|
+
)
|
|
1018
|
+
.select_from(AcquisitionSQLModel)
|
|
1019
|
+
.outerjoin(
|
|
1020
|
+
AcquisitionTaskSQLModel,
|
|
1021
|
+
AcquisitionTaskSQLModel.task_id == AcquisitionSQLModel.acquisition_task_id,
|
|
1022
|
+
)
|
|
1023
|
+
.outerjoin(
|
|
1024
|
+
ROISQLModel,
|
|
1025
|
+
ROISQLModel.roi_id == AcquisitionSQLModel.roi_id,
|
|
1026
|
+
)
|
|
1027
|
+
.outerjoin(
|
|
1028
|
+
SectionSQLModel,
|
|
1029
|
+
SectionSQLModel.section_id == ROISQLModel.section_id,
|
|
1030
|
+
)
|
|
1031
|
+
.outerjoin(
|
|
1032
|
+
SubstrateSQLModel,
|
|
1033
|
+
SubstrateSQLModel.media_id == ROISQLModel.substrate_media_id,
|
|
1034
|
+
)
|
|
1035
|
+
.outerjoin(
|
|
1036
|
+
CuttingSessionSQLModel,
|
|
1037
|
+
CuttingSessionSQLModel.cutting_session_id == SectionSQLModel.cutting_session_id,
|
|
1038
|
+
)
|
|
1039
|
+
.outerjoin(
|
|
1040
|
+
BlockSQLModel,
|
|
1041
|
+
and_(
|
|
1042
|
+
BlockSQLModel.block_id == CuttingSessionSQLModel.block_id,
|
|
1043
|
+
BlockSQLModel.specimen_id == CuttingSessionSQLModel.specimen_id,
|
|
1044
|
+
),
|
|
1045
|
+
)
|
|
1046
|
+
.outerjoin(
|
|
1047
|
+
SpecimenSQLModel,
|
|
1048
|
+
SpecimenSQLModel.specimen_id == AcquisitionSQLModel.specimen_id,
|
|
1049
|
+
)
|
|
1050
|
+
.where(*conditions)
|
|
1051
|
+
.order_by(order_column)
|
|
1052
|
+
)
|
|
1053
|
+
).all()
|
|
1054
|
+
|
|
1055
|
+
formatted_results = []
|
|
1056
|
+
for (
|
|
1057
|
+
acq,
|
|
1058
|
+
task_obj,
|
|
1059
|
+
roi_obj,
|
|
1060
|
+
section_obj,
|
|
1061
|
+
substrate_obj,
|
|
1062
|
+
cutting_obj,
|
|
1063
|
+
block_obj,
|
|
1064
|
+
specimen_obj,
|
|
1065
|
+
_specimen_ref,
|
|
1066
|
+
_roi_ref_value,
|
|
1067
|
+
_task_ref,
|
|
1068
|
+
) in rows:
|
|
1069
|
+
formatted_results.append(
|
|
1070
|
+
AcquisitionFullMetadata(
|
|
1071
|
+
acquisition=AcquisitionResponse.model_validate(acq),
|
|
1072
|
+
task=task_obj.model_dump() if task_obj else None,
|
|
1073
|
+
roi=roi_obj.model_dump() if roi_obj else None,
|
|
1074
|
+
section=section_obj.model_dump() if section_obj else None,
|
|
1075
|
+
substrate=substrate_obj.model_dump() if substrate_obj else None,
|
|
1076
|
+
cutting_session=cutting_obj.model_dump() if cutting_obj else None,
|
|
1077
|
+
block=block_obj.model_dump() if block_obj else None,
|
|
1078
|
+
specimen=specimen_obj.model_dump() if specimen_obj else None,
|
|
1079
|
+
).model_dump(mode="json", by_alias=True)
|
|
1080
|
+
)
|
|
1081
|
+
total_count = len(formatted_results)
|
|
1082
|
+
formatted_results = formatted_results[:limit]
|
|
1083
|
+
next_cursor = str(formatted_results[-1]["acquisition"]["id"]) if formatted_results else None
|
|
1084
|
+
metadata = {
|
|
1085
|
+
"total_count": total_count,
|
|
1086
|
+
"returned_count": len(formatted_results),
|
|
1087
|
+
"limit": limit,
|
|
1088
|
+
"sort_by": sort_by,
|
|
1089
|
+
"sort_order": sort_order if sort_order in [-1, 1] else -1,
|
|
1090
|
+
"next_cursor": next_cursor,
|
|
1091
|
+
}
|
|
1092
|
+
response.headers["Cache-Control"] = "private, max-age=300"
|
|
1093
|
+
response.headers["X-Total-Count"] = str(total_count)
|
|
1094
|
+
return {"acquisitions": formatted_results, "metadata": metadata}
|