fractal-server 2.13.0__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 (127) 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 +11 -1
  5. fractal_server/app/models/v2/accounting.py +35 -0
  6. fractal_server/app/models/v2/dataset.py +1 -11
  7. fractal_server/app/models/v2/history.py +78 -0
  8. fractal_server/app/models/v2/job.py +10 -3
  9. fractal_server/app/models/v2/task_group.py +2 -2
  10. fractal_server/app/models/v2/workflow.py +1 -1
  11. fractal_server/app/models/v2/workflowtask.py +1 -1
  12. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  13. fractal_server/app/routes/admin/v2/accounting.py +98 -0
  14. fractal_server/app/routes/admin/v2/impersonate.py +35 -0
  15. fractal_server/app/routes/admin/v2/job.py +5 -13
  16. fractal_server/app/routes/admin/v2/task.py +1 -1
  17. fractal_server/app/routes/admin/v2/task_group.py +4 -29
  18. fractal_server/app/routes/api/__init__.py +1 -1
  19. fractal_server/app/routes/api/v2/__init__.py +8 -2
  20. fractal_server/app/routes/api/v2/_aux_functions.py +66 -0
  21. fractal_server/app/routes/api/v2/_aux_functions_history.py +166 -0
  22. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
  23. fractal_server/app/routes/api/v2/dataset.py +0 -17
  24. fractal_server/app/routes/api/v2/history.py +544 -0
  25. fractal_server/app/routes/api/v2/images.py +31 -43
  26. fractal_server/app/routes/api/v2/job.py +30 -0
  27. fractal_server/app/routes/api/v2/project.py +1 -53
  28. fractal_server/app/routes/api/v2/{status.py → status_legacy.py} +6 -6
  29. fractal_server/app/routes/api/v2/submit.py +17 -14
  30. fractal_server/app/routes/api/v2/task.py +3 -10
  31. fractal_server/app/routes/api/v2/task_collection_custom.py +4 -9
  32. fractal_server/app/routes/api/v2/task_group.py +2 -22
  33. fractal_server/app/routes/api/v2/verify_image_types.py +61 -0
  34. fractal_server/app/routes/api/v2/workflow.py +28 -69
  35. fractal_server/app/routes/api/v2/workflowtask.py +53 -50
  36. fractal_server/app/routes/auth/group.py +0 -16
  37. fractal_server/app/routes/auth/oauth.py +5 -3
  38. fractal_server/app/routes/aux/__init__.py +0 -20
  39. fractal_server/app/routes/pagination.py +47 -0
  40. fractal_server/app/runner/components.py +0 -3
  41. fractal_server/app/runner/compress_folder.py +57 -29
  42. fractal_server/app/runner/exceptions.py +4 -0
  43. fractal_server/app/runner/executors/base_runner.py +157 -0
  44. fractal_server/app/runner/{v2/_local/_local_config.py → executors/local/get_local_config.py} +7 -9
  45. fractal_server/app/runner/executors/local/runner.py +248 -0
  46. fractal_server/app/runner/executors/{slurm → slurm_common}/_batching.py +1 -1
  47. fractal_server/app/runner/executors/{slurm → slurm_common}/_slurm_config.py +9 -7
  48. fractal_server/app/runner/executors/slurm_common/base_slurm_runner.py +868 -0
  49. fractal_server/app/runner/{v2/_slurm_common → executors/slurm_common}/get_slurm_config.py +48 -17
  50. fractal_server/app/runner/executors/{slurm → slurm_common}/remote.py +36 -47
  51. fractal_server/app/runner/executors/slurm_common/slurm_job_task_models.py +134 -0
  52. fractal_server/app/runner/executors/slurm_ssh/runner.py +268 -0
  53. fractal_server/app/runner/executors/slurm_sudo/__init__.py +0 -0
  54. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_subprocess_run_as_user.py +2 -83
  55. fractal_server/app/runner/executors/slurm_sudo/runner.py +193 -0
  56. fractal_server/app/runner/extract_archive.py +1 -3
  57. fractal_server/app/runner/task_files.py +134 -87
  58. fractal_server/app/runner/v2/__init__.py +0 -395
  59. fractal_server/app/runner/v2/_local.py +88 -0
  60. fractal_server/app/runner/v2/{_slurm_ssh/__init__.py → _slurm_ssh.py} +22 -19
  61. fractal_server/app/runner/v2/{_slurm_sudo/__init__.py → _slurm_sudo.py} +19 -15
  62. fractal_server/app/runner/v2/db_tools.py +119 -0
  63. fractal_server/app/runner/v2/runner.py +219 -98
  64. fractal_server/app/runner/v2/runner_functions.py +491 -189
  65. fractal_server/app/runner/v2/runner_functions_low_level.py +40 -43
  66. fractal_server/app/runner/v2/submit_workflow.py +358 -0
  67. fractal_server/app/runner/v2/task_interface.py +31 -0
  68. fractal_server/app/schemas/_validators.py +13 -24
  69. fractal_server/app/schemas/user.py +10 -7
  70. fractal_server/app/schemas/user_settings.py +9 -21
  71. fractal_server/app/schemas/v2/__init__.py +10 -1
  72. fractal_server/app/schemas/v2/accounting.py +18 -0
  73. fractal_server/app/schemas/v2/dataset.py +12 -94
  74. fractal_server/app/schemas/v2/dumps.py +26 -9
  75. fractal_server/app/schemas/v2/history.py +80 -0
  76. fractal_server/app/schemas/v2/job.py +15 -8
  77. fractal_server/app/schemas/v2/manifest.py +14 -7
  78. fractal_server/app/schemas/v2/project.py +9 -7
  79. fractal_server/app/schemas/v2/status_legacy.py +35 -0
  80. fractal_server/app/schemas/v2/task.py +72 -77
  81. fractal_server/app/schemas/v2/task_collection.py +14 -32
  82. fractal_server/app/schemas/v2/task_group.py +10 -9
  83. fractal_server/app/schemas/v2/workflow.py +10 -11
  84. fractal_server/app/schemas/v2/workflowtask.py +2 -21
  85. fractal_server/app/security/__init__.py +3 -3
  86. fractal_server/app/security/signup_email.py +2 -2
  87. fractal_server/config.py +91 -90
  88. fractal_server/images/tools.py +23 -0
  89. fractal_server/migrations/versions/47351f8c7ebc_drop_dataset_filters.py +50 -0
  90. fractal_server/migrations/versions/9db60297b8b2_set_ondelete.py +250 -0
  91. fractal_server/migrations/versions/af1ef1c83c9b_add_accounting_tables.py +57 -0
  92. fractal_server/migrations/versions/c90a7c76e996_job_id_in_history_run.py +41 -0
  93. fractal_server/migrations/versions/e81103413827_add_job_type_filters.py +36 -0
  94. fractal_server/migrations/versions/f37aceb45062_make_historyunit_logfile_required.py +39 -0
  95. fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +120 -0
  96. fractal_server/ssh/_fabric.py +28 -14
  97. fractal_server/tasks/v2/local/collect.py +2 -2
  98. fractal_server/tasks/v2/ssh/collect.py +2 -2
  99. fractal_server/tasks/v2/templates/2_pip_install.sh +1 -1
  100. fractal_server/tasks/v2/templates/4_pip_show.sh +1 -1
  101. fractal_server/tasks/v2/utils_background.py +1 -20
  102. fractal_server/tasks/v2/utils_database.py +30 -17
  103. fractal_server/tasks/v2/utils_templates.py +6 -0
  104. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/METADATA +4 -4
  105. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/RECORD +114 -99
  106. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/WHEEL +1 -1
  107. fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +0 -126
  108. fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +0 -116
  109. fractal_server/app/runner/executors/slurm/ssh/executor.py +0 -1386
  110. fractal_server/app/runner/executors/slurm/sudo/_check_jobs_status.py +0 -71
  111. fractal_server/app/runner/executors/slurm/sudo/_executor_wait_thread.py +0 -130
  112. fractal_server/app/runner/executors/slurm/sudo/executor.py +0 -1281
  113. fractal_server/app/runner/v2/_local/__init__.py +0 -129
  114. fractal_server/app/runner/v2/_local/_submit_setup.py +0 -52
  115. fractal_server/app/runner/v2/_local/executor.py +0 -100
  116. fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +0 -83
  117. fractal_server/app/runner/v2/_slurm_sudo/_submit_setup.py +0 -83
  118. fractal_server/app/runner/v2/handle_failed_job.py +0 -59
  119. fractal_server/app/schemas/v2/status.py +0 -16
  120. /fractal_server/app/{runner/executors/slurm → history}/__init__.py +0 -0
  121. /fractal_server/app/runner/executors/{slurm/ssh → local}/__init__.py +0 -0
  122. /fractal_server/app/runner/executors/{slurm/sudo → slurm_common}/__init__.py +0 -0
  123. /fractal_server/app/runner/executors/{_job_states.py → slurm_common/_job_states.py} +0 -0
  124. /fractal_server/app/runner/executors/{slurm → slurm_common}/utils_executors.py +0 -0
  125. /fractal_server/app/runner/{v2/_slurm_common → executors/slurm_ssh}/__init__.py +0 -0
  126. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/LICENSE +0 -0
  127. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -11,12 +11,17 @@ from pydantic import Field
11
11
  from pydantic import field_validator
12
12
  from pydantic import model_validator
13
13
  from sqlalchemy.orm.attributes import flag_modified
14
+ from sqlmodel import delete
14
15
 
15
16
  from ._aux_functions import _get_dataset_check_owner
16
17
  from fractal_server.app.db import AsyncSession
17
18
  from fractal_server.app.db import get_async_db
19
+ from fractal_server.app.models import HistoryImageCache
18
20
  from fractal_server.app.models import UserOAuth
19
21
  from fractal_server.app.routes.auth import current_active_user
22
+ from fractal_server.app.routes.pagination import get_pagination_params
23
+ from fractal_server.app.routes.pagination import PaginationRequest
24
+ from fractal_server.app.routes.pagination import PaginationResponse
20
25
  from fractal_server.app.schemas._filter_validators import (
21
26
  validate_attribute_filters,
22
27
  )
@@ -25,26 +30,21 @@ from fractal_server.app.schemas._validators import root_validate_dict_keys
25
30
  from fractal_server.images import SingleImage
26
31
  from fractal_server.images import SingleImageUpdate
27
32
  from fractal_server.images.models import AttributeFiltersType
33
+ from fractal_server.images.tools import aggregate_attributes
34
+ from fractal_server.images.tools import aggregate_types
28
35
  from fractal_server.images.tools import find_image_by_zarr_url
29
36
  from fractal_server.images.tools import match_filter
30
37
 
31
38
  router = APIRouter()
32
39
 
33
40
 
34
- class ImagePage(BaseModel):
35
-
36
- total_count: int
37
- page_size: int
38
- current_page: int
41
+ class ImagePage(PaginationResponse[SingleImage]):
39
42
 
40
43
  attributes: dict[str, list[Any]]
41
44
  types: list[str]
42
45
 
43
- images: list[SingleImage]
44
-
45
46
 
46
47
  class ImageQuery(BaseModel):
47
- zarr_url: Optional[str] = None
48
48
  type_filters: dict[str, bool] = Field(default_factory=dict)
49
49
  attribute_filters: AttributeFiltersType = Field(default_factory=dict)
50
50
 
@@ -59,6 +59,10 @@ class ImageQuery(BaseModel):
59
59
  )
60
60
 
61
61
 
62
+ class ImageQueryWithZarrUrl(ImageQuery):
63
+ zarr_url: Optional[str] = None
64
+
65
+
62
66
  @router.post(
63
67
  "/project/{project_id}/dataset/{dataset_id}/images/",
64
68
  status_code=status.HTTP_201_CREATED,
@@ -118,18 +122,14 @@ async def post_new_image(
118
122
  async def query_dataset_images(
119
123
  project_id: int,
120
124
  dataset_id: int,
121
- page: int = 1, # query param
122
- page_size: Optional[int] = None, # query param
123
- query: Optional[ImageQuery] = None, # body
125
+ query: Optional[ImageQueryWithZarrUrl] = None,
126
+ pagination: PaginationRequest = Depends(get_pagination_params),
124
127
  user: UserOAuth = Depends(current_active_user),
125
128
  db: AsyncSession = Depends(get_async_db),
126
129
  ) -> ImagePage:
127
130
 
128
- if page < 1:
129
- raise HTTPException(
130
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
131
- detail=f"Invalid pagination parameter: page={page} < 1",
132
- )
131
+ page = pagination.page
132
+ page_size = pagination.page_size
133
133
 
134
134
  output = await _get_dataset_check_owner(
135
135
  project_id=project_id, dataset_id=dataset_id, user_id=user.id, db=db
@@ -137,16 +137,8 @@ async def query_dataset_images(
137
137
  dataset = output["dataset"]
138
138
  images = dataset.images
139
139
 
140
- attributes = {}
141
- for image in images:
142
- for k, v in image["attributes"].items():
143
- attributes.setdefault(k, []).append(v)
144
- for k, v in attributes.items():
145
- attributes[k] = list(set(v))
146
-
147
- types = list(
148
- set(type for image in images for type in image["types"].keys())
149
- )
140
+ attributes = aggregate_attributes(images)
141
+ types = aggregate_types(images)
150
142
 
151
143
  if query is not None:
152
144
 
@@ -177,20 +169,10 @@ async def query_dataset_images(
177
169
 
178
170
  total_count = len(images)
179
171
 
180
- if page_size is not None:
181
- if page_size <= 0:
182
- raise HTTPException(
183
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
184
- detail=(
185
- f"Invalid pagination parameter: page_size={page_size} <= 0"
186
- ),
187
- )
188
- else:
172
+ if page_size is None:
189
173
  page_size = total_count
190
174
 
191
- if total_count == 0:
192
- page = 1
193
- else:
175
+ if total_count > 0:
194
176
  last_page = (total_count // page_size) + (total_count % page_size > 0)
195
177
  if page > last_page:
196
178
  page = last_page
@@ -201,9 +183,9 @@ async def query_dataset_images(
201
183
  total_count=total_count,
202
184
  current_page=page,
203
185
  page_size=page_size,
186
+ items=images,
204
187
  attributes=attributes,
205
188
  types=types,
206
- images=images,
207
189
  )
208
190
 
209
191
 
@@ -224,10 +206,10 @@ async def delete_dataset_images(
224
206
  )
225
207
  dataset = output["dataset"]
226
208
 
227
- image_to_remove = next(
228
- (image for image in dataset.images if image["zarr_url"] == zarr_url),
229
- None,
209
+ image_to_remove = find_image_by_zarr_url(
210
+ images=dataset.images, zarr_url=zarr_url
230
211
  )
212
+
231
213
  if image_to_remove is None:
232
214
  raise HTTPException(
233
215
  status_code=status.HTTP_404_NOT_FOUND,
@@ -237,9 +219,15 @@ async def delete_dataset_images(
237
219
  ),
238
220
  )
239
221
 
240
- dataset.images.remove(image_to_remove)
222
+ dataset.images.remove(image_to_remove["image"])
241
223
  flag_modified(dataset, "images")
242
224
 
225
+ await db.execute(
226
+ delete(HistoryImageCache)
227
+ .where(HistoryImageCache.dataset_id == dataset_id)
228
+ .where(HistoryImageCache.zarr_url == zarr_url)
229
+ )
230
+
243
231
  await db.commit()
244
232
 
245
233
  return Response(status_code=status.HTTP_204_NO_CONTENT)