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.
- fractal_server/__init__.py +1 -1
- fractal_server/__main__.py +3 -1
- fractal_server/app/models/linkusergroup.py +6 -2
- fractal_server/app/models/v2/__init__.py +11 -1
- fractal_server/app/models/v2/accounting.py +35 -0
- fractal_server/app/models/v2/dataset.py +1 -11
- fractal_server/app/models/v2/history.py +78 -0
- fractal_server/app/models/v2/job.py +10 -3
- fractal_server/app/models/v2/task_group.py +2 -2
- fractal_server/app/models/v2/workflow.py +1 -1
- fractal_server/app/models/v2/workflowtask.py +1 -1
- fractal_server/app/routes/admin/v2/__init__.py +4 -0
- fractal_server/app/routes/admin/v2/accounting.py +98 -0
- fractal_server/app/routes/admin/v2/impersonate.py +35 -0
- fractal_server/app/routes/admin/v2/job.py +5 -13
- fractal_server/app/routes/admin/v2/task.py +1 -1
- fractal_server/app/routes/admin/v2/task_group.py +4 -29
- fractal_server/app/routes/api/__init__.py +1 -1
- fractal_server/app/routes/api/v2/__init__.py +8 -2
- fractal_server/app/routes/api/v2/_aux_functions.py +66 -0
- fractal_server/app/routes/api/v2/_aux_functions_history.py +166 -0
- fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
- fractal_server/app/routes/api/v2/dataset.py +0 -17
- fractal_server/app/routes/api/v2/history.py +544 -0
- fractal_server/app/routes/api/v2/images.py +31 -43
- fractal_server/app/routes/api/v2/job.py +30 -0
- fractal_server/app/routes/api/v2/project.py +1 -53
- fractal_server/app/routes/api/v2/{status.py → status_legacy.py} +6 -6
- fractal_server/app/routes/api/v2/submit.py +17 -14
- fractal_server/app/routes/api/v2/task.py +3 -10
- fractal_server/app/routes/api/v2/task_collection_custom.py +4 -9
- fractal_server/app/routes/api/v2/task_group.py +2 -22
- fractal_server/app/routes/api/v2/verify_image_types.py +61 -0
- fractal_server/app/routes/api/v2/workflow.py +28 -69
- fractal_server/app/routes/api/v2/workflowtask.py +53 -50
- fractal_server/app/routes/auth/group.py +0 -16
- fractal_server/app/routes/auth/oauth.py +5 -3
- fractal_server/app/routes/aux/__init__.py +0 -20
- fractal_server/app/routes/pagination.py +47 -0
- fractal_server/app/runner/components.py +0 -3
- fractal_server/app/runner/compress_folder.py +57 -29
- fractal_server/app/runner/exceptions.py +4 -0
- fractal_server/app/runner/executors/base_runner.py +157 -0
- fractal_server/app/runner/{v2/_local/_local_config.py → executors/local/get_local_config.py} +7 -9
- fractal_server/app/runner/executors/local/runner.py +248 -0
- fractal_server/app/runner/executors/{slurm → slurm_common}/_batching.py +1 -1
- fractal_server/app/runner/executors/{slurm → slurm_common}/_slurm_config.py +9 -7
- fractal_server/app/runner/executors/slurm_common/base_slurm_runner.py +868 -0
- fractal_server/app/runner/{v2/_slurm_common → executors/slurm_common}/get_slurm_config.py +48 -17
- fractal_server/app/runner/executors/{slurm → slurm_common}/remote.py +36 -47
- fractal_server/app/runner/executors/slurm_common/slurm_job_task_models.py +134 -0
- fractal_server/app/runner/executors/slurm_ssh/runner.py +268 -0
- fractal_server/app/runner/executors/slurm_sudo/__init__.py +0 -0
- fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_subprocess_run_as_user.py +2 -83
- fractal_server/app/runner/executors/slurm_sudo/runner.py +193 -0
- fractal_server/app/runner/extract_archive.py +1 -3
- fractal_server/app/runner/task_files.py +134 -87
- fractal_server/app/runner/v2/__init__.py +0 -395
- fractal_server/app/runner/v2/_local.py +88 -0
- fractal_server/app/runner/v2/{_slurm_ssh/__init__.py → _slurm_ssh.py} +22 -19
- fractal_server/app/runner/v2/{_slurm_sudo/__init__.py → _slurm_sudo.py} +19 -15
- fractal_server/app/runner/v2/db_tools.py +119 -0
- fractal_server/app/runner/v2/runner.py +219 -98
- fractal_server/app/runner/v2/runner_functions.py +491 -189
- fractal_server/app/runner/v2/runner_functions_low_level.py +40 -43
- fractal_server/app/runner/v2/submit_workflow.py +358 -0
- fractal_server/app/runner/v2/task_interface.py +31 -0
- fractal_server/app/schemas/_validators.py +13 -24
- fractal_server/app/schemas/user.py +10 -7
- fractal_server/app/schemas/user_settings.py +9 -21
- fractal_server/app/schemas/v2/__init__.py +10 -1
- fractal_server/app/schemas/v2/accounting.py +18 -0
- fractal_server/app/schemas/v2/dataset.py +12 -94
- fractal_server/app/schemas/v2/dumps.py +26 -9
- fractal_server/app/schemas/v2/history.py +80 -0
- fractal_server/app/schemas/v2/job.py +15 -8
- fractal_server/app/schemas/v2/manifest.py +14 -7
- fractal_server/app/schemas/v2/project.py +9 -7
- fractal_server/app/schemas/v2/status_legacy.py +35 -0
- fractal_server/app/schemas/v2/task.py +72 -77
- fractal_server/app/schemas/v2/task_collection.py +14 -32
- fractal_server/app/schemas/v2/task_group.py +10 -9
- fractal_server/app/schemas/v2/workflow.py +10 -11
- fractal_server/app/schemas/v2/workflowtask.py +2 -21
- fractal_server/app/security/__init__.py +3 -3
- fractal_server/app/security/signup_email.py +2 -2
- fractal_server/config.py +91 -90
- fractal_server/images/tools.py +23 -0
- fractal_server/migrations/versions/47351f8c7ebc_drop_dataset_filters.py +50 -0
- fractal_server/migrations/versions/9db60297b8b2_set_ondelete.py +250 -0
- fractal_server/migrations/versions/af1ef1c83c9b_add_accounting_tables.py +57 -0
- fractal_server/migrations/versions/c90a7c76e996_job_id_in_history_run.py +41 -0
- fractal_server/migrations/versions/e81103413827_add_job_type_filters.py +36 -0
- fractal_server/migrations/versions/f37aceb45062_make_historyunit_logfile_required.py +39 -0
- fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +120 -0
- fractal_server/ssh/_fabric.py +28 -14
- fractal_server/tasks/v2/local/collect.py +2 -2
- fractal_server/tasks/v2/ssh/collect.py +2 -2
- fractal_server/tasks/v2/templates/2_pip_install.sh +1 -1
- fractal_server/tasks/v2/templates/4_pip_show.sh +1 -1
- fractal_server/tasks/v2/utils_background.py +1 -20
- fractal_server/tasks/v2/utils_database.py +30 -17
- fractal_server/tasks/v2/utils_templates.py +6 -0
- {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/METADATA +4 -4
- {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/RECORD +114 -99
- {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/WHEEL +1 -1
- fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +0 -126
- fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +0 -116
- fractal_server/app/runner/executors/slurm/ssh/executor.py +0 -1386
- fractal_server/app/runner/executors/slurm/sudo/_check_jobs_status.py +0 -71
- fractal_server/app/runner/executors/slurm/sudo/_executor_wait_thread.py +0 -130
- fractal_server/app/runner/executors/slurm/sudo/executor.py +0 -1281
- fractal_server/app/runner/v2/_local/__init__.py +0 -129
- fractal_server/app/runner/v2/_local/_submit_setup.py +0 -52
- fractal_server/app/runner/v2/_local/executor.py +0 -100
- fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +0 -83
- fractal_server/app/runner/v2/_slurm_sudo/_submit_setup.py +0 -83
- fractal_server/app/runner/v2/handle_failed_job.py +0 -59
- fractal_server/app/schemas/v2/status.py +0 -16
- /fractal_server/app/{runner/executors/slurm → history}/__init__.py +0 -0
- /fractal_server/app/runner/executors/{slurm/ssh → local}/__init__.py +0 -0
- /fractal_server/app/runner/executors/{slurm/sudo → slurm_common}/__init__.py +0 -0
- /fractal_server/app/runner/executors/{_job_states.py → slurm_common/_job_states.py} +0 -0
- /fractal_server/app/runner/executors/{slurm → slurm_common}/utils_executors.py +0 -0
- /fractal_server/app/runner/{v2/_slurm_common → executors/slurm_ssh}/__init__.py +0 -0
- {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/LICENSE +0 -0
- {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(
|
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
|
-
|
122
|
-
|
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
|
-
|
129
|
-
|
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
|
-
|
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
|
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
|
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 =
|
228
|
-
|
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)
|