fractal-server 2.13.1__py3-none-any.whl → 2.14.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.
Files changed (119) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +3 -1
  3. fractal_server/app/models/linkusergroup.py +6 -2
  4. fractal_server/app/models/v2/__init__.py +7 -1
  5. fractal_server/app/models/v2/dataset.py +1 -11
  6. fractal_server/app/models/v2/history.py +78 -0
  7. fractal_server/app/models/v2/job.py +10 -3
  8. fractal_server/app/models/v2/task_group.py +2 -2
  9. fractal_server/app/models/v2/workflow.py +1 -1
  10. fractal_server/app/models/v2/workflowtask.py +1 -1
  11. fractal_server/app/routes/admin/v2/accounting.py +18 -28
  12. fractal_server/app/routes/admin/v2/task.py +1 -1
  13. fractal_server/app/routes/admin/v2/task_group.py +0 -17
  14. fractal_server/app/routes/api/__init__.py +1 -1
  15. fractal_server/app/routes/api/v2/__init__.py +8 -2
  16. fractal_server/app/routes/api/v2/_aux_functions.py +66 -0
  17. fractal_server/app/routes/api/v2/_aux_functions_history.py +166 -0
  18. fractal_server/app/routes/api/v2/dataset.py +0 -17
  19. fractal_server/app/routes/api/v2/history.py +544 -0
  20. fractal_server/app/routes/api/v2/images.py +31 -43
  21. fractal_server/app/routes/api/v2/job.py +30 -0
  22. fractal_server/app/routes/api/v2/project.py +1 -53
  23. fractal_server/app/routes/api/v2/{status.py → status_legacy.py} +6 -6
  24. fractal_server/app/routes/api/v2/submit.py +16 -14
  25. fractal_server/app/routes/api/v2/task.py +3 -10
  26. fractal_server/app/routes/api/v2/task_collection_custom.py +4 -9
  27. fractal_server/app/routes/api/v2/task_group.py +0 -17
  28. fractal_server/app/routes/api/v2/verify_image_types.py +61 -0
  29. fractal_server/app/routes/api/v2/workflow.py +28 -69
  30. fractal_server/app/routes/api/v2/workflowtask.py +53 -50
  31. fractal_server/app/routes/auth/group.py +0 -16
  32. fractal_server/app/routes/auth/oauth.py +5 -3
  33. fractal_server/app/routes/pagination.py +47 -0
  34. fractal_server/app/runner/components.py +0 -3
  35. fractal_server/app/runner/compress_folder.py +57 -29
  36. fractal_server/app/runner/exceptions.py +4 -0
  37. fractal_server/app/runner/executors/base_runner.py +157 -0
  38. fractal_server/app/runner/{v2/_local/_local_config.py → executors/local/get_local_config.py} +7 -9
  39. fractal_server/app/runner/executors/local/runner.py +248 -0
  40. fractal_server/app/runner/executors/{slurm → slurm_common}/_batching.py +1 -1
  41. fractal_server/app/runner/executors/{slurm → slurm_common}/_slurm_config.py +9 -7
  42. fractal_server/app/runner/executors/slurm_common/base_slurm_runner.py +868 -0
  43. fractal_server/app/runner/{v2/_slurm_common → executors/slurm_common}/get_slurm_config.py +48 -17
  44. fractal_server/app/runner/executors/{slurm → slurm_common}/remote.py +36 -47
  45. fractal_server/app/runner/executors/slurm_common/slurm_job_task_models.py +134 -0
  46. fractal_server/app/runner/executors/slurm_ssh/runner.py +268 -0
  47. fractal_server/app/runner/executors/slurm_sudo/__init__.py +0 -0
  48. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_subprocess_run_as_user.py +2 -83
  49. fractal_server/app/runner/executors/slurm_sudo/runner.py +193 -0
  50. fractal_server/app/runner/extract_archive.py +1 -3
  51. fractal_server/app/runner/task_files.py +134 -87
  52. fractal_server/app/runner/v2/__init__.py +0 -399
  53. fractal_server/app/runner/v2/_local.py +88 -0
  54. fractal_server/app/runner/v2/{_slurm_ssh/__init__.py → _slurm_ssh.py} +20 -19
  55. fractal_server/app/runner/v2/{_slurm_sudo/__init__.py → _slurm_sudo.py} +17 -15
  56. fractal_server/app/runner/v2/db_tools.py +119 -0
  57. fractal_server/app/runner/v2/runner.py +206 -95
  58. fractal_server/app/runner/v2/runner_functions.py +488 -187
  59. fractal_server/app/runner/v2/runner_functions_low_level.py +40 -43
  60. fractal_server/app/runner/v2/submit_workflow.py +358 -0
  61. fractal_server/app/runner/v2/task_interface.py +31 -0
  62. fractal_server/app/schemas/_validators.py +13 -24
  63. fractal_server/app/schemas/user.py +10 -7
  64. fractal_server/app/schemas/user_settings.py +9 -21
  65. fractal_server/app/schemas/v2/__init__.py +9 -1
  66. fractal_server/app/schemas/v2/dataset.py +12 -94
  67. fractal_server/app/schemas/v2/dumps.py +26 -9
  68. fractal_server/app/schemas/v2/history.py +80 -0
  69. fractal_server/app/schemas/v2/job.py +15 -8
  70. fractal_server/app/schemas/v2/manifest.py +14 -7
  71. fractal_server/app/schemas/v2/project.py +9 -7
  72. fractal_server/app/schemas/v2/status_legacy.py +35 -0
  73. fractal_server/app/schemas/v2/task.py +72 -77
  74. fractal_server/app/schemas/v2/task_collection.py +14 -32
  75. fractal_server/app/schemas/v2/task_group.py +10 -9
  76. fractal_server/app/schemas/v2/workflow.py +10 -11
  77. fractal_server/app/schemas/v2/workflowtask.py +2 -21
  78. fractal_server/app/security/__init__.py +3 -3
  79. fractal_server/app/security/signup_email.py +2 -2
  80. fractal_server/config.py +41 -46
  81. fractal_server/images/tools.py +23 -0
  82. fractal_server/migrations/versions/47351f8c7ebc_drop_dataset_filters.py +50 -0
  83. fractal_server/migrations/versions/9db60297b8b2_set_ondelete.py +250 -0
  84. fractal_server/migrations/versions/c90a7c76e996_job_id_in_history_run.py +41 -0
  85. fractal_server/migrations/versions/e81103413827_add_job_type_filters.py +36 -0
  86. fractal_server/migrations/versions/f37aceb45062_make_historyunit_logfile_required.py +39 -0
  87. fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +120 -0
  88. fractal_server/ssh/_fabric.py +28 -14
  89. fractal_server/tasks/v2/local/collect.py +2 -2
  90. fractal_server/tasks/v2/ssh/collect.py +2 -2
  91. fractal_server/tasks/v2/templates/2_pip_install.sh +1 -1
  92. fractal_server/tasks/v2/templates/4_pip_show.sh +1 -1
  93. fractal_server/tasks/v2/utils_background.py +0 -19
  94. fractal_server/tasks/v2/utils_database.py +30 -17
  95. fractal_server/tasks/v2/utils_templates.py +6 -0
  96. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0.dist-info}/METADATA +4 -4
  97. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0.dist-info}/RECORD +106 -96
  98. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0.dist-info}/WHEEL +1 -1
  99. fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +0 -126
  100. fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +0 -116
  101. fractal_server/app/runner/executors/slurm/ssh/executor.py +0 -1386
  102. fractal_server/app/runner/executors/slurm/sudo/_check_jobs_status.py +0 -71
  103. fractal_server/app/runner/executors/slurm/sudo/_executor_wait_thread.py +0 -130
  104. fractal_server/app/runner/executors/slurm/sudo/executor.py +0 -1281
  105. fractal_server/app/runner/v2/_local/__init__.py +0 -132
  106. fractal_server/app/runner/v2/_local/_submit_setup.py +0 -52
  107. fractal_server/app/runner/v2/_local/executor.py +0 -100
  108. fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +0 -83
  109. fractal_server/app/runner/v2/_slurm_sudo/_submit_setup.py +0 -83
  110. fractal_server/app/runner/v2/handle_failed_job.py +0 -59
  111. fractal_server/app/schemas/v2/status.py +0 -16
  112. /fractal_server/app/{runner/executors/slurm → history}/__init__.py +0 -0
  113. /fractal_server/app/runner/executors/{slurm/ssh → local}/__init__.py +0 -0
  114. /fractal_server/app/runner/executors/{slurm/sudo → slurm_common}/__init__.py +0 -0
  115. /fractal_server/app/runner/executors/{_job_states.py → slurm_common/_job_states.py} +0 -0
  116. /fractal_server/app/runner/executors/{slurm → slurm_common}/utils_executors.py +0 -0
  117. /fractal_server/app/runner/{v2/_slurm_common → executors/slurm_ssh}/__init__.py +0 -0
  118. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0.dist-info}/LICENSE +0 -0
  119. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0.dist-info}/entry_points.txt +0 -0
@@ -47,7 +47,6 @@ async def create_dataset(
47
47
  )
48
48
 
49
49
  if dataset.zarr_dir is None:
50
-
51
50
  if user.settings.project_dir is None:
52
51
  raise HTTPException(
53
52
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -91,7 +90,6 @@ async def create_dataset(
91
90
  )
92
91
  async def read_dataset_list(
93
92
  project_id: int,
94
- history: bool = True,
95
93
  user: UserOAuth = Depends(current_active_user),
96
94
  db: AsyncSession = Depends(get_async_db),
97
95
  ) -> Optional[list[DatasetReadV2]]:
@@ -110,9 +108,6 @@ async def read_dataset_list(
110
108
  res = await db.execute(stm)
111
109
  dataset_list = res.scalars().all()
112
110
  await db.close()
113
- if not history:
114
- for ds in dataset_list:
115
- setattr(ds, "history", [])
116
111
  return dataset_list
117
112
 
118
113
 
@@ -217,14 +212,6 @@ async def delete_dataset(
217
212
  ),
218
213
  )
219
214
 
220
- # Cascade operations: set foreign-keys to null for jobs which are in
221
- # relationship with the current dataset
222
- stm = select(JobV2).where(JobV2.dataset_id == dataset_id)
223
- res = await db.execute(stm)
224
- jobs = res.scalars().all()
225
- for job in jobs:
226
- job.dataset_id = None
227
-
228
215
  # Delete dataset
229
216
  await db.delete(dataset)
230
217
  await db.commit()
@@ -234,7 +221,6 @@ async def delete_dataset(
234
221
 
235
222
  @router.get("/dataset/", response_model=list[DatasetReadV2])
236
223
  async def get_user_datasets(
237
- history: bool = True,
238
224
  user: UserOAuth = Depends(current_active_user),
239
225
  db: AsyncSession = Depends(get_async_db),
240
226
  ) -> list[DatasetReadV2]:
@@ -249,9 +235,6 @@ async def get_user_datasets(
249
235
  res = await db.execute(stm)
250
236
  dataset_list = res.scalars().all()
251
237
  await db.close()
252
- if not history:
253
- for ds in dataset_list:
254
- setattr(ds, "history", [])
255
238
  return dataset_list
256
239
 
257
240
 
@@ -0,0 +1,544 @@
1
+ from copy import deepcopy
2
+ from typing import Any
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter
6
+ from fastapi import Depends
7
+ from fastapi import HTTPException
8
+ from fastapi import status
9
+ from fastapi.responses import JSONResponse
10
+ from sqlmodel import func
11
+ from sqlmodel import select
12
+
13
+ from ._aux_functions import _get_dataset_check_owner
14
+ from ._aux_functions import _get_workflow_check_owner
15
+ from ._aux_functions_history import _verify_workflow_and_dataset_access
16
+ from ._aux_functions_history import get_history_run_or_404
17
+ from ._aux_functions_history import get_history_unit_or_404
18
+ from ._aux_functions_history import get_wftask_check_owner
19
+ from ._aux_functions_history import read_log_file
20
+ from .images import ImageQuery
21
+ from fractal_server.app.db import AsyncSession
22
+ from fractal_server.app.db import get_async_db
23
+ from fractal_server.app.models import UserOAuth
24
+ from fractal_server.app.models.v2 import HistoryImageCache
25
+ from fractal_server.app.models.v2 import HistoryRun
26
+ from fractal_server.app.models.v2 import HistoryUnit
27
+ from fractal_server.app.routes.auth import current_active_user
28
+ from fractal_server.app.routes.pagination import get_pagination_params
29
+ from fractal_server.app.routes.pagination import PaginationRequest
30
+ from fractal_server.app.routes.pagination import PaginationResponse
31
+ from fractal_server.app.schemas.v2 import HistoryRunRead
32
+ from fractal_server.app.schemas.v2 import HistoryRunReadAggregated
33
+ from fractal_server.app.schemas.v2 import HistoryUnitRead
34
+ from fractal_server.app.schemas.v2 import HistoryUnitStatus
35
+ from fractal_server.app.schemas.v2 import HistoryUnitStatusQuery
36
+ from fractal_server.app.schemas.v2 import ImageLogsRequest
37
+ from fractal_server.app.schemas.v2 import SingleImageWithStatus
38
+ from fractal_server.images.tools import aggregate_attributes
39
+ from fractal_server.images.tools import aggregate_types
40
+ from fractal_server.images.tools import filter_image_list
41
+ from fractal_server.images.tools import merge_type_filters
42
+ from fractal_server.logger import set_logger
43
+
44
+
45
+ def check_historyrun_related_to_dataset_and_wftask(
46
+ history_run: HistoryRun,
47
+ dataset_id: int,
48
+ workflowtask_id: int,
49
+ ):
50
+ if (
51
+ history_run.dataset_id != dataset_id
52
+ or history_run.workflowtask_id != workflowtask_id
53
+ ):
54
+ raise HTTPException(
55
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
56
+ detail=(
57
+ f"Invalid query parameters: HistoryRun[{history_run.id}] is "
58
+ f"not related to {dataset_id=} and {workflowtask_id=}."
59
+ ),
60
+ )
61
+
62
+
63
+ class ImageWithStatusPage(PaginationResponse[SingleImageWithStatus]):
64
+
65
+ attributes: dict[str, list[Any]]
66
+ types: list[str]
67
+
68
+
69
+ router = APIRouter()
70
+ logger = set_logger(__name__)
71
+
72
+
73
+ @router.get("/project/{project_id}/status/")
74
+ async def get_workflow_tasks_statuses(
75
+ project_id: int,
76
+ dataset_id: int,
77
+ workflow_id: int,
78
+ user: UserOAuth = Depends(current_active_user),
79
+ db: AsyncSession = Depends(get_async_db),
80
+ ) -> JSONResponse:
81
+
82
+ # Access control
83
+ workflow = await _get_workflow_check_owner(
84
+ project_id=project_id,
85
+ workflow_id=workflow_id,
86
+ user_id=user.id,
87
+ db=db,
88
+ )
89
+ await _get_dataset_check_owner(
90
+ project_id=project_id,
91
+ dataset_id=dataset_id,
92
+ user_id=user.id,
93
+ db=db,
94
+ )
95
+
96
+ response: dict[int, dict[str, int | str] | None] = {}
97
+ for wftask in workflow.task_list:
98
+ res = await db.execute(
99
+ select(HistoryRun)
100
+ .where(HistoryRun.dataset_id == dataset_id)
101
+ .where(HistoryRun.workflowtask_id == wftask.id)
102
+ .order_by(HistoryRun.timestamp_started.desc())
103
+ .limit(1)
104
+ )
105
+ latest_history_run = res.scalar_one_or_none()
106
+ if latest_history_run is None:
107
+ logger.debug(
108
+ f"No HistoryRun found for {dataset_id=} and {wftask.id=}."
109
+ )
110
+ response[wftask.id] = None
111
+ continue
112
+ response[wftask.id] = dict(
113
+ status=latest_history_run.status,
114
+ num_available_images=latest_history_run.num_available_images,
115
+ )
116
+
117
+ for target_status in HistoryUnitStatus:
118
+ stm = (
119
+ select(func.count(HistoryImageCache.zarr_url))
120
+ .join(HistoryUnit)
121
+ .where(HistoryImageCache.dataset_id == dataset_id)
122
+ .where(HistoryImageCache.workflowtask_id == wftask.id)
123
+ .where(
124
+ HistoryImageCache.latest_history_unit_id == HistoryUnit.id
125
+ )
126
+ .where(HistoryUnit.status == target_status.value)
127
+ )
128
+ res = await db.execute(stm)
129
+ num_images = res.scalar()
130
+ response[wftask.id][
131
+ f"num_{target_status.value}_images"
132
+ ] = num_images
133
+
134
+ new_response = deepcopy(response)
135
+ for key, value in response.items():
136
+ if value is not None:
137
+ num_total_images = sum(
138
+ value[f"num_{target_status.value}_images"]
139
+ for target_status in HistoryUnitStatus
140
+ )
141
+ if num_total_images > value["num_available_images"]:
142
+ value["num_available_images"] = None
143
+ new_response[key] = value
144
+
145
+ return JSONResponse(content=new_response, status_code=200)
146
+
147
+
148
+ @router.get("/project/{project_id}/status/run/")
149
+ async def get_history_run_list(
150
+ project_id: int,
151
+ dataset_id: int,
152
+ workflowtask_id: int,
153
+ user: UserOAuth = Depends(current_active_user),
154
+ db: AsyncSession = Depends(get_async_db),
155
+ ) -> list[HistoryRunReadAggregated]:
156
+
157
+ # Access control
158
+ await get_wftask_check_owner(
159
+ project_id=project_id,
160
+ dataset_id=dataset_id,
161
+ workflowtask_id=workflowtask_id,
162
+ user_id=user.id,
163
+ db=db,
164
+ )
165
+
166
+ # Get all runs
167
+ stm = (
168
+ select(HistoryRun)
169
+ .where(HistoryRun.dataset_id == dataset_id)
170
+ .where(HistoryRun.workflowtask_id == workflowtask_id)
171
+ .order_by(HistoryRun.timestamp_started)
172
+ )
173
+ res = await db.execute(stm)
174
+ runs = res.scalars().all()
175
+
176
+ # Respond early if there are no runs
177
+ if not runs:
178
+ return []
179
+
180
+ # Add units count by status
181
+ run_ids = [run.id for run in runs]
182
+ stm = (
183
+ select(
184
+ HistoryUnit.history_run_id,
185
+ HistoryUnit.status,
186
+ func.count(HistoryUnit.id),
187
+ )
188
+ .where(HistoryUnit.history_run_id.in_(run_ids))
189
+ .group_by(HistoryUnit.history_run_id, HistoryUnit.status)
190
+ )
191
+ res = await db.execute(stm)
192
+ unit_counts = res.all()
193
+
194
+ count_map = {
195
+ run_id: {
196
+ "num_done_units": 0,
197
+ "num_submitted_units": 0,
198
+ "num_failed_units": 0,
199
+ }
200
+ for run_id in run_ids
201
+ }
202
+ for run_id, unit_status, count in unit_counts:
203
+ count_map[run_id][f"num_{unit_status}_units"] = count
204
+
205
+ runs = [dict(**run.model_dump(), **count_map[run.id]) for run in runs]
206
+
207
+ return runs
208
+
209
+
210
+ @router.get("/project/{project_id}/status/run/{history_run_id}/units/")
211
+ async def get_history_run_units(
212
+ project_id: int,
213
+ dataset_id: int,
214
+ workflowtask_id: int,
215
+ history_run_id: int,
216
+ unit_status: Optional[HistoryUnitStatus] = None,
217
+ user: UserOAuth = Depends(current_active_user),
218
+ db: AsyncSession = Depends(get_async_db),
219
+ pagination: PaginationRequest = Depends(get_pagination_params),
220
+ ) -> PaginationResponse[HistoryUnitRead]:
221
+
222
+ # Access control
223
+ await get_wftask_check_owner(
224
+ project_id=project_id,
225
+ dataset_id=dataset_id,
226
+ workflowtask_id=workflowtask_id,
227
+ user_id=user.id,
228
+ db=db,
229
+ )
230
+
231
+ # Check that `HistoryRun` exists
232
+ history_run = await get_history_run_or_404(
233
+ history_run_id=history_run_id, db=db
234
+ )
235
+ check_historyrun_related_to_dataset_and_wftask(
236
+ history_run=history_run,
237
+ dataset_id=dataset_id,
238
+ workflowtask_id=workflowtask_id,
239
+ )
240
+
241
+ # Count `HistoryUnit`s
242
+ stmt = select(func.count(HistoryUnit.id)).where(
243
+ HistoryUnit.history_run_id == history_run_id
244
+ )
245
+ if unit_status:
246
+ stmt = stmt.where(HistoryUnit.status == unit_status)
247
+ res = await db.execute(stmt)
248
+ total_count = res.scalar()
249
+ page_size = pagination.page_size or total_count
250
+
251
+ # Query `HistoryUnit`s
252
+ stmt = (
253
+ select(HistoryUnit)
254
+ .where(HistoryUnit.history_run_id == history_run_id)
255
+ .order_by(HistoryUnit.id)
256
+ )
257
+ if unit_status:
258
+ stmt = stmt.where(HistoryUnit.status == unit_status)
259
+ stmt = stmt.offset((pagination.page - 1) * page_size).limit(page_size)
260
+ res = await db.execute(stmt)
261
+ units = res.scalars().all()
262
+
263
+ return dict(
264
+ current_page=pagination.page,
265
+ page_size=page_size,
266
+ total_count=total_count,
267
+ items=units,
268
+ )
269
+
270
+
271
+ @router.post("/project/{project_id}/status/images/")
272
+ async def get_history_images(
273
+ project_id: int,
274
+ dataset_id: int,
275
+ workflowtask_id: int,
276
+ request_body: ImageQuery,
277
+ unit_status: Optional[HistoryUnitStatusQuery] = None,
278
+ user: UserOAuth = Depends(current_active_user),
279
+ db: AsyncSession = Depends(get_async_db),
280
+ pagination: PaginationRequest = Depends(get_pagination_params),
281
+ ) -> ImageWithStatusPage:
282
+
283
+ # Access control and object retrieval
284
+ wftask = await get_wftask_check_owner(
285
+ project_id=project_id,
286
+ dataset_id=dataset_id,
287
+ workflowtask_id=workflowtask_id,
288
+ user_id=user.id,
289
+ db=db,
290
+ )
291
+ res = await _verify_workflow_and_dataset_access(
292
+ project_id=project_id,
293
+ workflow_id=wftask.workflow_id,
294
+ dataset_id=dataset_id,
295
+ user_id=user.id,
296
+ db=db,
297
+ )
298
+ dataset = res["dataset"]
299
+ workflow = res["workflow"]
300
+
301
+ # Setup prefix for logging
302
+ prefix = f"[DS{dataset.id}-WFT{wftask.id}-images]"
303
+
304
+ # (1) Get the type-filtered list of dataset images
305
+
306
+ # (1A) Reconstruct dataset type filters by starting from {} and making
307
+ # incremental updates with `output_types` of all previous tasks
308
+ inferred_dataset_type_filters = {}
309
+ for current_wftask in workflow.task_list[0 : wftask.order]:
310
+ inferred_dataset_type_filters.update(current_wftask.task.output_types)
311
+ logger.debug(f"{prefix} {inferred_dataset_type_filters=}")
312
+ # (1B) Compute type filters for the current wftask
313
+ type_filters_patch = merge_type_filters(
314
+ task_input_types=wftask.task.input_types,
315
+ wftask_type_filters=wftask.type_filters,
316
+ )
317
+ logger.debug(f"{prefix} {type_filters_patch=}")
318
+ # (1C) Combine dataset type filters (lower priority) and current-wftask
319
+ # filters (higher priority)
320
+ actual_filters = inferred_dataset_type_filters
321
+ actual_filters.update(type_filters_patch)
322
+ logger.debug(f"{prefix} {actual_filters=}")
323
+ # (1D) Get all matching images from the dataset
324
+ pre_filtered_dataset_images = filter_image_list(
325
+ images=dataset.images,
326
+ type_filters=inferred_dataset_type_filters,
327
+ )
328
+ filtered_dataset_images = filter_image_list(
329
+ pre_filtered_dataset_images,
330
+ type_filters=request_body.type_filters,
331
+ attribute_filters=request_body.attribute_filters,
332
+ )
333
+ logger.debug(f"{prefix} {len(dataset.images)=}")
334
+ logger.debug(f"{prefix} {len(filtered_dataset_images)=}")
335
+ # (1E) Extract the list of URLs for filtered images
336
+ filtered_dataset_images_url = list(
337
+ img["zarr_url"] for img in filtered_dataset_images
338
+ )
339
+
340
+ # (2) Get `(zarr_url, status)` pairs for all images that have already
341
+ # been processed, and
342
+ # (3) When relevant, find images that have not been processed
343
+ base_stmt = (
344
+ select(HistoryImageCache.zarr_url, HistoryUnit.status)
345
+ .join(HistoryUnit)
346
+ .where(HistoryImageCache.dataset_id == dataset_id)
347
+ .where(HistoryImageCache.workflowtask_id == workflowtask_id)
348
+ .where(HistoryImageCache.latest_history_unit_id == HistoryUnit.id)
349
+ .where(HistoryImageCache.zarr_url.in_(filtered_dataset_images_url))
350
+ )
351
+
352
+ if unit_status in [HistoryUnitStatusQuery.UNSET, None]:
353
+ stmt = base_stmt.order_by(HistoryImageCache.zarr_url)
354
+ res = await db.execute(stmt)
355
+ list_processed_url_status = res.all()
356
+ list_processed_url = list(
357
+ item[0] for item in list_processed_url_status
358
+ )
359
+ list_non_processed_url_status = list(
360
+ (url, None)
361
+ for url in filtered_dataset_images_url
362
+ if url not in list_processed_url
363
+ )
364
+ if unit_status == HistoryUnitStatusQuery.UNSET:
365
+ list_processed_url_status = []
366
+ else:
367
+ stmt = base_stmt.where(HistoryUnit.status == unit_status).order_by(
368
+ HistoryImageCache.zarr_url
369
+ )
370
+ res = await db.execute(stmt)
371
+ list_processed_url_status = res.all()
372
+ list_non_processed_url_status = []
373
+
374
+ logger.debug(f"{prefix} {len(list_processed_url_status)=}")
375
+ logger.debug(f"{prefix} {len(list_non_processed_url_status)=}")
376
+
377
+ # (3) Combine outputs from 1 and 2
378
+ full_list_url_status = (
379
+ list_processed_url_status + list_non_processed_url_status
380
+ )
381
+ logger.debug(f"{prefix} {len(full_list_url_status)=}")
382
+
383
+ attributes = aggregate_attributes(pre_filtered_dataset_images)
384
+ types = aggregate_types(pre_filtered_dataset_images)
385
+
386
+ sorted_list_url_status = sorted(
387
+ full_list_url_status,
388
+ key=lambda url_status: url_status[0],
389
+ )
390
+ logger.debug(f"{prefix} {len(sorted_list_url_status)=}")
391
+
392
+ # Final list of objects
393
+
394
+ total_count = len(sorted_list_url_status)
395
+ page_size = pagination.page_size or total_count
396
+
397
+ paginated_list_url_status = sorted_list_url_status[
398
+ (pagination.page - 1) * page_size : pagination.page * page_size
399
+ ]
400
+
401
+ # Aggregate information to create 'SingleImageWithStatus'
402
+ items = [
403
+ {
404
+ **filtered_dataset_images[
405
+ filtered_dataset_images_url.index(url_status[0])
406
+ ],
407
+ "status": url_status[1],
408
+ }
409
+ for url_status in paginated_list_url_status
410
+ ]
411
+
412
+ return dict(
413
+ current_page=pagination.page,
414
+ page_size=page_size,
415
+ total_count=total_count,
416
+ items=items,
417
+ attributes=attributes,
418
+ types=types,
419
+ )
420
+
421
+
422
+ @router.post("/project/{project_id}/status/image-log/")
423
+ async def get_image_log(
424
+ project_id: int,
425
+ request_data: ImageLogsRequest,
426
+ user: UserOAuth = Depends(current_active_user),
427
+ db: AsyncSession = Depends(get_async_db),
428
+ ) -> JSONResponse:
429
+ # Access control
430
+ wftask = await get_wftask_check_owner(
431
+ project_id=project_id,
432
+ dataset_id=request_data.dataset_id,
433
+ workflowtask_id=request_data.workflowtask_id,
434
+ user_id=user.id,
435
+ db=db,
436
+ )
437
+
438
+ # Get HistoryImageCache
439
+ history_image_cache = await db.get(
440
+ HistoryImageCache,
441
+ (
442
+ request_data.zarr_url,
443
+ request_data.dataset_id,
444
+ request_data.workflowtask_id,
445
+ ),
446
+ )
447
+ if history_image_cache is None:
448
+ raise HTTPException(
449
+ status_code=status.HTTP_404_NOT_FOUND,
450
+ detail="HistoryImageCache not found",
451
+ )
452
+ # Get history unit
453
+ history_unit = await get_history_unit_or_404(
454
+ history_unit_id=history_image_cache.latest_history_unit_id,
455
+ db=db,
456
+ )
457
+
458
+ # Get log or placeholder text
459
+ log = read_log_file(
460
+ logfile=history_unit.logfile,
461
+ wftask=wftask,
462
+ dataset_id=request_data.dataset_id,
463
+ )
464
+ return JSONResponse(content=log)
465
+
466
+
467
+ @router.get("/project/{project_id}/status/unit-log/")
468
+ async def get_history_unit_log(
469
+ project_id: int,
470
+ history_run_id: int,
471
+ history_unit_id: int,
472
+ workflowtask_id: int,
473
+ dataset_id: int,
474
+ user: UserOAuth = Depends(current_active_user),
475
+ db: AsyncSession = Depends(get_async_db),
476
+ ) -> JSONResponse:
477
+ # Access control
478
+ wftask = await get_wftask_check_owner(
479
+ project_id=project_id,
480
+ dataset_id=dataset_id,
481
+ workflowtask_id=workflowtask_id,
482
+ user_id=user.id,
483
+ db=db,
484
+ )
485
+
486
+ # Get history unit
487
+ history_unit = await get_history_unit_or_404(
488
+ history_unit_id=history_unit_id,
489
+ db=db,
490
+ )
491
+
492
+ if history_unit.history_run_id != history_run_id:
493
+ raise HTTPException(
494
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
495
+ detail=(
496
+ f"Invalid query parameters: HistoryUnit[{history_unit_id}] "
497
+ f"is not related to HistoryRun[{history_run_id}]"
498
+ ),
499
+ )
500
+ history_run = await get_history_run_or_404(
501
+ history_run_id=history_run_id, db=db
502
+ )
503
+ check_historyrun_related_to_dataset_and_wftask(
504
+ history_run=history_run,
505
+ dataset_id=dataset_id,
506
+ workflowtask_id=workflowtask_id,
507
+ )
508
+
509
+ # Get log or placeholder text
510
+ log = read_log_file(
511
+ logfile=history_unit.logfile,
512
+ wftask=wftask,
513
+ dataset_id=dataset_id,
514
+ )
515
+ return JSONResponse(content=log)
516
+
517
+
518
+ @router.get("/project/{project_id}/dataset/{dataset_id}/history/")
519
+ async def get_dataset_history(
520
+ project_id: int,
521
+ dataset_id: int,
522
+ user: UserOAuth = Depends(current_active_user),
523
+ db: AsyncSession = Depends(get_async_db),
524
+ ) -> list[HistoryRunRead]:
525
+ """
526
+ Returns a list of all HistoryRuns associated to a given dataset, sorted by
527
+ timestamp.
528
+ """
529
+ # Access control
530
+ await _get_dataset_check_owner(
531
+ project_id=project_id,
532
+ dataset_id=dataset_id,
533
+ user_id=user.id,
534
+ db=db,
535
+ )
536
+
537
+ res = await db.execute(
538
+ select(HistoryRun)
539
+ .where(HistoryRun.dataset_id == dataset_id)
540
+ .order_by(HistoryRun.timestamp_started)
541
+ )
542
+ history_run_list = res.scalars().all()
543
+
544
+ return history_run_list