fractal-server 2.13.1__py3-none-any.whl → 2.14.0a1__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 (60) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/history/__init__.py +4 -0
  3. fractal_server/app/history/image_updates.py +142 -0
  4. fractal_server/app/history/status_enum.py +16 -0
  5. fractal_server/app/models/v2/__init__.py +5 -1
  6. fractal_server/app/models/v2/history.py +53 -0
  7. fractal_server/app/routes/api/v2/__init__.py +2 -2
  8. fractal_server/app/routes/api/v2/_aux_functions.py +78 -0
  9. fractal_server/app/routes/api/v2/dataset.py +12 -9
  10. fractal_server/app/routes/api/v2/history.py +247 -0
  11. fractal_server/app/routes/api/v2/project.py +25 -0
  12. fractal_server/app/routes/api/v2/workflow.py +18 -3
  13. fractal_server/app/routes/api/v2/workflowtask.py +22 -0
  14. fractal_server/app/runner/executors/base_runner.py +114 -0
  15. fractal_server/app/runner/{v2/_local → executors/local}/_local_config.py +3 -3
  16. fractal_server/app/runner/executors/local/_submit_setup.py +54 -0
  17. fractal_server/app/runner/executors/local/runner.py +200 -0
  18. fractal_server/app/runner/executors/{slurm → slurm_common}/_batching.py +1 -1
  19. fractal_server/app/runner/executors/{slurm → slurm_common}/_slurm_config.py +3 -3
  20. fractal_server/app/runner/{v2/_slurm_ssh → executors/slurm_common}/_submit_setup.py +13 -12
  21. fractal_server/app/runner/{v2/_slurm_common → executors/slurm_common}/get_slurm_config.py +9 -15
  22. fractal_server/app/runner/executors/{slurm/ssh → slurm_ssh}/_executor_wait_thread.py +1 -1
  23. fractal_server/app/runner/executors/{slurm/ssh → slurm_ssh}/_slurm_job.py +1 -1
  24. fractal_server/app/runner/executors/{slurm/ssh → slurm_ssh}/executor.py +13 -14
  25. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_check_jobs_status.py +11 -9
  26. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_executor_wait_thread.py +3 -3
  27. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_subprocess_run_as_user.py +2 -68
  28. fractal_server/app/runner/executors/slurm_sudo/runner.py +632 -0
  29. fractal_server/app/runner/task_files.py +70 -96
  30. fractal_server/app/runner/v2/__init__.py +5 -19
  31. fractal_server/app/runner/v2/_local.py +84 -0
  32. fractal_server/app/runner/v2/{_slurm_ssh/__init__.py → _slurm_ssh.py} +10 -13
  33. fractal_server/app/runner/v2/{_slurm_sudo/__init__.py → _slurm_sudo.py} +10 -12
  34. fractal_server/app/runner/v2/runner.py +93 -28
  35. fractal_server/app/runner/v2/runner_functions.py +85 -62
  36. fractal_server/app/runner/v2/runner_functions_low_level.py +20 -20
  37. fractal_server/app/schemas/v2/dataset.py +0 -17
  38. fractal_server/app/schemas/v2/history.py +23 -0
  39. fractal_server/config.py +2 -2
  40. fractal_server/migrations/versions/8223fcef886c_image_status.py +63 -0
  41. fractal_server/migrations/versions/87cd72a537a2_add_historyitem_table.py +68 -0
  42. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0a1.dist-info}/METADATA +1 -1
  43. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0a1.dist-info}/RECORD +53 -47
  44. fractal_server/app/routes/api/v2/status.py +0 -168
  45. fractal_server/app/runner/executors/slurm/sudo/executor.py +0 -1281
  46. fractal_server/app/runner/v2/_local/__init__.py +0 -132
  47. fractal_server/app/runner/v2/_local/_submit_setup.py +0 -52
  48. fractal_server/app/runner/v2/_local/executor.py +0 -100
  49. fractal_server/app/runner/v2/_slurm_sudo/_submit_setup.py +0 -83
  50. fractal_server/app/runner/v2/handle_failed_job.py +0 -59
  51. /fractal_server/app/runner/executors/{slurm → local}/__init__.py +0 -0
  52. /fractal_server/app/runner/executors/{slurm/ssh → slurm_common}/__init__.py +0 -0
  53. /fractal_server/app/runner/executors/{_job_states.py → slurm_common/_job_states.py} +0 -0
  54. /fractal_server/app/runner/executors/{slurm → slurm_common}/remote.py +0 -0
  55. /fractal_server/app/runner/executors/{slurm → slurm_common}/utils_executors.py +0 -0
  56. /fractal_server/app/runner/executors/{slurm/sudo → slurm_ssh}/__init__.py +0 -0
  57. /fractal_server/app/runner/{v2/_slurm_common → executors/slurm_sudo}/__init__.py +0 -0
  58. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0a1.dist-info}/LICENSE +0 -0
  59. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0a1.dist-info}/WHEEL +0 -0
  60. {fractal_server-2.13.1.dist-info → fractal_server-2.14.0a1.dist-info}/entry_points.txt +0 -0
@@ -1 +1 @@
1
- __VERSION__ = "2.13.1"
1
+ __VERSION__ = "2.14.0a1"
@@ -0,0 +1,4 @@
1
+ from .image_updates import update_all_images # noqa: F401
2
+ from .image_updates import update_single_image # noqa
3
+ from .image_updates import update_single_image_logfile # noqa
4
+ from .status_enum import HistoryItemImageStatus # noqa: F401
@@ -0,0 +1,142 @@
1
+ from typing import Optional
2
+
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy.orm.attributes import flag_modified
5
+ from sqlmodel import select
6
+
7
+ from fractal_server.app.db import get_sync_db
8
+ from fractal_server.app.history.status_enum import HistoryItemImageStatus
9
+ from fractal_server.app.models.v2 import HistoryItemV2
10
+ from fractal_server.app.models.v2 import ImageStatus
11
+ from fractal_server.logger import set_logger
12
+
13
+ logger = set_logger(__name__)
14
+
15
+
16
+ def _update_single_image_status(
17
+ *,
18
+ zarr_url: str,
19
+ workflowtask_id: int,
20
+ dataset_id: int,
21
+ status: HistoryItemImageStatus,
22
+ db: Session,
23
+ commit: bool = True,
24
+ logfile: Optional[str] = None,
25
+ ) -> None:
26
+ image_status = db.get(
27
+ ImageStatus,
28
+ (
29
+ zarr_url,
30
+ workflowtask_id,
31
+ dataset_id,
32
+ ),
33
+ )
34
+ if image_status is None:
35
+ raise RuntimeError("This should have not happened")
36
+ image_status.status = status
37
+ if logfile is not None:
38
+ image_status.logfile = logfile
39
+ db.add(image_status)
40
+ if commit:
41
+ db.commit()
42
+
43
+
44
+ def update_single_image(
45
+ *,
46
+ history_item_id: int,
47
+ zarr_url: str,
48
+ status: HistoryItemImageStatus,
49
+ ) -> None:
50
+
51
+ logger.debug(
52
+ f"[update_single_image] {history_item_id=}, {status=}, {zarr_url=}"
53
+ )
54
+
55
+ # Note: thanks to `with_for_update`, a lock is acquired and kept
56
+ # until `db.commit()`
57
+ with next(get_sync_db()) as db:
58
+ stm = (
59
+ select(HistoryItemV2)
60
+ .where(HistoryItemV2.id == history_item_id)
61
+ .with_for_update(nowait=False)
62
+ )
63
+ history_item = db.execute(stm).scalar_one()
64
+ history_item.images[zarr_url] = status
65
+ flag_modified(history_item, "images")
66
+ db.commit()
67
+
68
+ _update_single_image_status(
69
+ zarr_url=zarr_url,
70
+ dataset_id=history_item.dataset_id,
71
+ workflowtask_id=history_item.workflowtask_id,
72
+ commit=True,
73
+ status=status,
74
+ db=db,
75
+ )
76
+
77
+
78
+ def update_single_image_logfile(
79
+ *,
80
+ history_item_id: int,
81
+ zarr_url: str,
82
+ logfile: str,
83
+ ) -> None:
84
+
85
+ logger.debug(
86
+ f"[update_single_image_logfile] {history_item_id=}, {logfile=}, {zarr_url=}"
87
+ )
88
+
89
+ with next(get_sync_db()) as db:
90
+ history_item = db.get(HistoryItemV2, history_item_id)
91
+ image_status = db.get(
92
+ ImageStatus,
93
+ (
94
+ zarr_url,
95
+ history_item.workflowtask_id,
96
+ history_item.dataset_id,
97
+ ),
98
+ )
99
+ if image_status is None:
100
+ raise RuntimeError("This should have not happened")
101
+ image_status.logfile = logfile
102
+ db.merge(image_status)
103
+ db.commit()
104
+
105
+
106
+ def update_all_images(
107
+ *,
108
+ history_item_id: int,
109
+ status: HistoryItemImageStatus,
110
+ logfile: Optional[str] = None,
111
+ ) -> None:
112
+
113
+ logger.debug(f"[update_all_images] {history_item_id=}, {status=}")
114
+
115
+ # Note: thanks to `with_for_update`, a lock is acquired and kept
116
+ # until `db.commit()`
117
+ stm = (
118
+ select(HistoryItemV2)
119
+ .where(HistoryItemV2.id == history_item_id)
120
+ .with_for_update(nowait=False)
121
+ )
122
+ with next(get_sync_db()) as db:
123
+ history_item = db.execute(stm).scalar_one()
124
+ new_images = {
125
+ zarr_url: status for zarr_url in history_item.images.keys()
126
+ }
127
+ history_item.images = new_images
128
+ flag_modified(history_item, "images")
129
+ db.commit()
130
+
131
+ # FIXME: Make this a bulk edit, if possible
132
+ for ind, zarr_url in enumerate(history_item.images.keys()):
133
+ _update_single_image_status(
134
+ zarr_url=zarr_url,
135
+ dataset_id=history_item.dataset_id,
136
+ workflowtask_id=history_item.workflowtask_id,
137
+ commit=False,
138
+ status=status,
139
+ logfile=logfile,
140
+ db=db,
141
+ )
142
+ db.commit()
@@ -0,0 +1,16 @@
1
+ from enum import Enum
2
+
3
+
4
+ class HistoryItemImageStatus(str, Enum):
5
+ """
6
+ Available image-status values within a `HistoryItemV2`
7
+
8
+ Attributes:
9
+ SUBMITTED:
10
+ DONE:
11
+ FAILED:
12
+ """
13
+
14
+ SUBMITTED = "submitted"
15
+ DONE = "done"
16
+ FAILED = "failed"
@@ -5,6 +5,8 @@ from ..linkuserproject import LinkUserProjectV2
5
5
  from .accounting import AccountingRecord
6
6
  from .accounting import AccountingRecordSlurm
7
7
  from .dataset import DatasetV2
8
+ from .history import HistoryItemV2
9
+ from .history import ImageStatus
8
10
  from .job import JobV2
9
11
  from .project import ProjectV2
10
12
  from .task import TaskV2
@@ -23,6 +25,8 @@ __all__ = [
23
25
  "TaskGroupV2",
24
26
  "TaskGroupActivityV2",
25
27
  "TaskV2",
26
- "WorkflowTaskV2",
27
28
  "WorkflowV2",
29
+ "WorkflowTaskV2",
30
+ "HistoryItemV2",
31
+ "ImageStatus",
28
32
  ]
@@ -0,0 +1,53 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from typing import Optional
4
+
5
+ from pydantic import ConfigDict
6
+ from sqlalchemy import Column
7
+ from sqlalchemy.dialects.postgresql import JSONB
8
+ from sqlalchemy.types import DateTime
9
+ from sqlmodel import Field
10
+ from sqlmodel import SQLModel
11
+
12
+ from ....utils import get_timestamp
13
+
14
+
15
+ class HistoryItemV2(SQLModel, table=True):
16
+ model_config = ConfigDict(arbitrary_types_allowed=True)
17
+
18
+ id: Optional[int] = Field(default=None, primary_key=True)
19
+ dataset_id: int = Field(foreign_key="datasetv2.id")
20
+ workflowtask_id: Optional[int] = Field(
21
+ foreign_key="workflowtaskv2.id",
22
+ default=None,
23
+ )
24
+ timestamp_started: datetime = Field(
25
+ default_factory=get_timestamp,
26
+ sa_column=Column(
27
+ DateTime(timezone=True),
28
+ nullable=False,
29
+ ),
30
+ )
31
+ workflowtask_dump: dict[str, Any] = Field(
32
+ sa_column=Column(JSONB, nullable=False)
33
+ )
34
+ task_group_dump: dict[str, Any] = Field(
35
+ sa_column=Column(JSONB, nullable=False)
36
+ )
37
+ parameters_hash: str
38
+ num_available_images: int
39
+ num_current_images: int
40
+ images: dict[str, str] = Field(sa_column=Column(JSONB, nullable=False))
41
+
42
+
43
+ class ImageStatus(SQLModel, table=True):
44
+
45
+ zarr_url: str = Field(primary_key=True)
46
+ workflowtask_id: int = Field(
47
+ primary_key=True, foreign_key="workflowtaskv2.id"
48
+ )
49
+ dataset_id: int = Field(primary_key=True, foreign_key="datasetv2.id")
50
+
51
+ parameters_hash: str
52
+ status: str
53
+ logfile: str
@@ -4,10 +4,10 @@
4
4
  from fastapi import APIRouter
5
5
 
6
6
  from .dataset import router as dataset_router_v2
7
+ from .history import router as history_router_v2
7
8
  from .images import router as images_routes_v2
8
9
  from .job import router as job_router_v2
9
10
  from .project import router as project_router_v2
10
- from .status import router as status_router_v2
11
11
  from .submit import router as submit_job_router_v2
12
12
  from .task import router as task_router_v2
13
13
  from .task_collection import router as task_collection_router_v2
@@ -28,6 +28,7 @@ router_api_v2.include_router(job_router_v2, tags=["V2 Job"])
28
28
  router_api_v2.include_router(images_routes_v2, tags=["V2 Images"])
29
29
  router_api_v2.include_router(project_router_v2, tags=["V2 Project"])
30
30
  router_api_v2.include_router(submit_job_router_v2, tags=["V2 Job"])
31
+ router_api_v2.include_router(history_router_v2, tags=["V2 History"])
31
32
 
32
33
 
33
34
  settings = Inject(get_settings)
@@ -56,4 +57,3 @@ router_api_v2.include_router(
56
57
  workflow_import_router_v2, tags=["V2 Workflow Import"]
57
58
  )
58
59
  router_api_v2.include_router(workflowtask_router_v2, tags=["V2 WorkflowTask"])
59
- router_api_v2.include_router(status_router_v2, tags=["V2 Status"])
@@ -417,3 +417,81 @@ async def clean_app_job_list_v2(
417
417
  if job.status == JobStatusTypeV2.SUBMITTED
418
418
  ]
419
419
  return submitted_job_ids
420
+
421
+
422
+ async def _get_workflow_check_history_owner(
423
+ *,
424
+ workflow_id: int,
425
+ dataset_id: int,
426
+ user_id: int,
427
+ db: AsyncSession,
428
+ ) -> list[int]:
429
+ """
430
+ Verify user access for the history of this dataset and workflowtask.
431
+
432
+ Args:
433
+ dataset_id:
434
+ workflow_task_id:
435
+ user_id:
436
+ db:
437
+
438
+ Returns:
439
+ List of WorkflowTask IDs
440
+ """
441
+ workflow = await db.get(WorkflowV2, workflow_id)
442
+ if workflow is None:
443
+ raise HTTPException(
444
+ status_code=status.HTTP_404_NOT_FOUND,
445
+ detail="Workflow not found.",
446
+ )
447
+ await _get_project_check_owner(
448
+ project_id=workflow.project_id,
449
+ user_id=user_id,
450
+ db=db,
451
+ )
452
+ dataset = await db.get(DatasetV2, dataset_id)
453
+ if dataset is None:
454
+ raise HTTPException(
455
+ status_code=status.HTTP_404_NOT_FOUND,
456
+ detail="Dataset not found.",
457
+ )
458
+ if workflow.project_id != dataset.project_id:
459
+ raise HTTPException(
460
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
461
+ detail="Dataset and workflow belong to different projects.",
462
+ )
463
+
464
+ return [wftask.id for wftask in workflow.task_list]
465
+
466
+
467
+ async def _get_workflowtask_check_history_owner(
468
+ *,
469
+ workflowtask_id: int,
470
+ dataset_id: int,
471
+ user_id: int,
472
+ db: AsyncSession,
473
+ ) -> list[int]:
474
+ """
475
+ Verify user access for the history of this dataset and workflowtask.
476
+
477
+ Args:
478
+ dataset_id:
479
+ workflow_task_id:
480
+ user_id:
481
+ db:
482
+
483
+ Returns:
484
+ List of WorkflowTask IDs
485
+ """
486
+ workflowtask = await db.get(WorkflowTaskV2, workflowtask_id)
487
+ if workflowtask is None:
488
+ raise HTTPException(
489
+ status_code=status.HTTP_404_NOT_FOUND,
490
+ detail="WorkflowTask not found.",
491
+ )
492
+ await _get_workflow_check_history_owner(
493
+ workflow_id=workflowtask.workflow_id,
494
+ dataset_id=dataset_id,
495
+ user_id=user_id,
496
+ db=db,
497
+ )
@@ -5,11 +5,14 @@ from fastapi import Depends
5
5
  from fastapi import HTTPException
6
6
  from fastapi import Response
7
7
  from fastapi import status
8
+ from sqlmodel import delete
8
9
  from sqlmodel import select
9
10
 
10
11
  from ....db import AsyncSession
11
12
  from ....db import get_async_db
12
13
  from ....models.v2 import DatasetV2
14
+ from ....models.v2 import HistoryItemV2
15
+ from ....models.v2 import ImageStatus
13
16
  from ....models.v2 import JobV2
14
17
  from ....models.v2 import ProjectV2
15
18
  from ....schemas.v2 import DatasetCreateV2
@@ -47,7 +50,6 @@ async def create_dataset(
47
50
  )
48
51
 
49
52
  if dataset.zarr_dir is None:
50
-
51
53
  if user.settings.project_dir is None:
52
54
  raise HTTPException(
53
55
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -91,7 +93,6 @@ async def create_dataset(
91
93
  )
92
94
  async def read_dataset_list(
93
95
  project_id: int,
94
- history: bool = True,
95
96
  user: UserOAuth = Depends(current_active_user),
96
97
  db: AsyncSession = Depends(get_async_db),
97
98
  ) -> Optional[list[DatasetReadV2]]:
@@ -110,9 +111,6 @@ async def read_dataset_list(
110
111
  res = await db.execute(stm)
111
112
  dataset_list = res.scalars().all()
112
113
  await db.close()
113
- if not history:
114
- for ds in dataset_list:
115
- setattr(ds, "history", [])
116
114
  return dataset_list
117
115
 
118
116
 
@@ -225,6 +223,15 @@ async def delete_dataset(
225
223
  for job in jobs:
226
224
  job.dataset_id = None
227
225
 
226
+ # Cascade operations: delete history items and image status which are in
227
+ # relationship with the current dataset
228
+
229
+ stm = delete(HistoryItemV2).where(HistoryItemV2.dataset_id == dataset_id)
230
+ await db.execute(stm)
231
+
232
+ stm = delete(ImageStatus).where(ImageStatus.dataset_id == dataset_id)
233
+ await db.execute(stm)
234
+
228
235
  # Delete dataset
229
236
  await db.delete(dataset)
230
237
  await db.commit()
@@ -234,7 +241,6 @@ async def delete_dataset(
234
241
 
235
242
  @router.get("/dataset/", response_model=list[DatasetReadV2])
236
243
  async def get_user_datasets(
237
- history: bool = True,
238
244
  user: UserOAuth = Depends(current_active_user),
239
245
  db: AsyncSession = Depends(get_async_db),
240
246
  ) -> list[DatasetReadV2]:
@@ -249,9 +255,6 @@ async def get_user_datasets(
249
255
  res = await db.execute(stm)
250
256
  dataset_list = res.scalars().all()
251
257
  await db.close()
252
- if not history:
253
- for ds in dataset_list:
254
- setattr(ds, "history", [])
255
258
  return dataset_list
256
259
 
257
260
 
@@ -0,0 +1,247 @@
1
+ from typing import Optional
2
+
3
+ from fastapi import APIRouter
4
+ from fastapi import Depends
5
+ from fastapi import HTTPException
6
+ from fastapi import Query
7
+ from fastapi import status
8
+ from fastapi.responses import JSONResponse
9
+ from sqlmodel import func
10
+ from sqlmodel import select
11
+
12
+ from ._aux_functions import _get_dataset_check_owner
13
+ from ._aux_functions import _get_workflow_check_owner
14
+ from ._aux_functions import _get_workflow_task_check_owner
15
+ from fractal_server.app.db import AsyncSession
16
+ from fractal_server.app.db import get_async_db
17
+ from fractal_server.app.history.status_enum import HistoryItemImageStatus
18
+ from fractal_server.app.models import UserOAuth
19
+ from fractal_server.app.models.v2 import HistoryItemV2
20
+ from fractal_server.app.models.v2 import ImageStatus
21
+ from fractal_server.app.models.v2 import WorkflowTaskV2
22
+ from fractal_server.app.routes.auth import current_active_user
23
+ from fractal_server.app.schemas.v2.history import HistoryItemV2Read
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ @router.get(
29
+ "/project/{project_id}/dataset/{dataset_id}/history/",
30
+ response_model=list[HistoryItemV2Read],
31
+ )
32
+ async def get_dataset_history(
33
+ project_id: int,
34
+ dataset_id: int,
35
+ user: UserOAuth = Depends(current_active_user),
36
+ db: AsyncSession = Depends(get_async_db),
37
+ ) -> list[HistoryItemV2Read]:
38
+ await _get_dataset_check_owner(
39
+ project_id=project_id,
40
+ dataset_id=dataset_id,
41
+ user_id=user.id,
42
+ db=db,
43
+ )
44
+
45
+ stm = (
46
+ select(HistoryItemV2)
47
+ .where(HistoryItemV2.dataset_id == dataset_id)
48
+ .order_by(HistoryItemV2.timestamp_started)
49
+ )
50
+ res = await db.execute(stm)
51
+ items = res.scalars().all()
52
+ return items
53
+
54
+
55
+ @router.get("/project/{project_id}/status/")
56
+ async def get_per_workflow_aggregated_info(
57
+ project_id: int,
58
+ workflow_id: int,
59
+ dataset_id: int,
60
+ user: UserOAuth = Depends(current_active_user),
61
+ db: AsyncSession = Depends(get_async_db),
62
+ ) -> JSONResponse:
63
+ workflow = await _get_workflow_check_owner(
64
+ project_id=project_id,
65
+ workflow_id=workflow_id,
66
+ user_id=user.id,
67
+ db=db,
68
+ )
69
+
70
+ wft_ids = [wftask.id for wftask in workflow.task_list]
71
+
72
+ # num_available_images
73
+ stm = (
74
+ select(
75
+ HistoryItemV2.workflowtask_id, HistoryItemV2.num_available_images
76
+ )
77
+ .where(HistoryItemV2.dataset_id == dataset_id)
78
+ .where(HistoryItemV2.workflowtask_id.in_(wft_ids))
79
+ .order_by(
80
+ HistoryItemV2.workflowtask_id,
81
+ HistoryItemV2.timestamp_started.desc(),
82
+ )
83
+ # https://www.postgresql.org/docs/current/sql-select.html#SQL-DISTINCT
84
+ .distinct(HistoryItemV2.workflowtask_id)
85
+ )
86
+ res = await db.execute(stm)
87
+ num_available_images = {k: v for k, v in res.all()}
88
+
89
+ count = {}
90
+ for _status in HistoryItemImageStatus:
91
+ stm = (
92
+ select(ImageStatus.workflowtask_id, func.count())
93
+ .where(ImageStatus.dataset_id == dataset_id)
94
+ .where(ImageStatus.workflowtask_id.in_(wft_ids))
95
+ .where(ImageStatus.status == _status)
96
+ # https://docs.sqlalchemy.org/en/20/tutorial/data_select.html#tutorial-group-by-w-aggregates
97
+ .group_by(ImageStatus.workflowtask_id)
98
+ )
99
+ res = await db.execute(stm)
100
+ count[_status] = {k: v for k, v in res.all()}
101
+
102
+ result = {
103
+ str(_id): None
104
+ if _id not in num_available_images
105
+ else {
106
+ "num_available_images": num_available_images[_id],
107
+ "num_done_images": count["done"].get(_id, 0),
108
+ "num_submitted_images": count["submitted"].get(_id, 0),
109
+ "num_failed_images": count["failed"].get(_id, 0),
110
+ }
111
+ for _id in wft_ids
112
+ }
113
+
114
+ return JSONResponse(content=result, status_code=200)
115
+
116
+
117
+ @router.get("/project/{project_id}/status/subsets/")
118
+ async def get_per_workflowtask_subsets_aggregated_info(
119
+ project_id: int,
120
+ workflowtask_id: int,
121
+ dataset_id: int,
122
+ user: UserOAuth = Depends(current_active_user),
123
+ db: AsyncSession = Depends(get_async_db),
124
+ ) -> JSONResponse:
125
+ wftask = await db.get(WorkflowTaskV2, workflowtask_id)
126
+ if wftask is None:
127
+ raise HTTPException(
128
+ status_code=status.HTTP_404_NOT_FOUND,
129
+ detail="WorkflowTask not found",
130
+ )
131
+ await _get_workflow_task_check_owner(
132
+ project_id=project_id,
133
+ workflow_id=wftask.workflow_id,
134
+ workflow_task_id=workflowtask_id,
135
+ user_id=user.id,
136
+ db=db,
137
+ )
138
+
139
+ stm = (
140
+ select(ImageStatus.parameters_hash, func.array_agg(ImageStatus.status))
141
+ .where(ImageStatus.dataset_id == dataset_id)
142
+ .where(ImageStatus.workflowtask_id == workflowtask_id)
143
+ .group_by(ImageStatus.parameters_hash)
144
+ )
145
+ res = await db.execute(stm)
146
+ hash_statuses = res.all()
147
+
148
+ result = []
149
+ for _hash, statuses in hash_statuses:
150
+ dump = await db.execute(
151
+ select(HistoryItemV2.workflowtask_dump)
152
+ .where(HistoryItemV2.workflowtask_id == workflowtask_id)
153
+ .where(HistoryItemV2.dataset_id == dataset_id)
154
+ .where(HistoryItemV2.parameters_hash == _hash)
155
+ )
156
+ result.append(
157
+ {
158
+ "workflowtask_dump": dump.scalar_one(),
159
+ "parameters_hash": _hash,
160
+ "info": {
161
+ "num_done_images": statuses.count(
162
+ HistoryItemImageStatus.DONE
163
+ ),
164
+ "num_failed_images": statuses.count(
165
+ HistoryItemImageStatus.FAILED
166
+ ),
167
+ "num_submitted_images": statuses.count(
168
+ HistoryItemImageStatus.SUBMITTED
169
+ ),
170
+ },
171
+ }
172
+ )
173
+
174
+ return JSONResponse(content=result, status_code=200)
175
+
176
+
177
+ @router.get("/project/{project_id}/status/images/")
178
+ async def get_per_workflowtask_images(
179
+ project_id: int,
180
+ workflowtask_id: int,
181
+ dataset_id: int,
182
+ status: HistoryItemImageStatus,
183
+ parameters_hash: Optional[str] = None,
184
+ # Pagination
185
+ page: int = Query(default=1, ge=1),
186
+ page_size: Optional[int] = Query(default=None, ge=1),
187
+ # Dependencies
188
+ user: UserOAuth = Depends(current_active_user),
189
+ db: AsyncSession = Depends(get_async_db),
190
+ ) -> JSONResponse:
191
+
192
+ if page_size is None and page > 1:
193
+ raise HTTPException(
194
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
195
+ detail=(f"Invalid pagination parameters: {page=}, {page_size=}."),
196
+ )
197
+
198
+ wftask = await db.get(WorkflowTaskV2, workflowtask_id)
199
+ if wftask is None:
200
+ raise HTTPException(
201
+ status_code=status.HTTP_404_NOT_FOUND,
202
+ detail="WorkflowTask not found",
203
+ )
204
+ await _get_workflow_task_check_owner(
205
+ project_id=project_id,
206
+ workflow_id=wftask.workflow_id,
207
+ workflow_task_id=workflowtask_id,
208
+ user_id=user.id,
209
+ db=db,
210
+ )
211
+
212
+ total_count_stm = (
213
+ select(func.count(ImageStatus.zarr_url))
214
+ .where(ImageStatus.dataset_id == dataset_id)
215
+ .where(ImageStatus.workflowtask_id == workflowtask_id)
216
+ .where(ImageStatus.status == status)
217
+ )
218
+ query = (
219
+ select(ImageStatus.zarr_url)
220
+ .where(ImageStatus.dataset_id == dataset_id)
221
+ .where(ImageStatus.workflowtask_id == workflowtask_id)
222
+ .where(ImageStatus.status == status)
223
+ )
224
+
225
+ if parameters_hash is not None:
226
+ total_count_stm = total_count_stm.where(
227
+ ImageStatus.parameters_hash == parameters_hash
228
+ )
229
+ query = query.where(ImageStatus.parameters_hash == parameters_hash)
230
+
231
+ if page_size is not None:
232
+ query = query.limit(page_size)
233
+ if page > 1:
234
+ query = query.offset((page - 1) * page_size)
235
+
236
+ res_total_count = await db.execute(total_count_stm)
237
+ total_count = res_total_count.scalar()
238
+
239
+ res = await db.execute(query)
240
+ images = res.scalars().all()
241
+
242
+ return {
243
+ "total_count": total_count,
244
+ "page_size": page_size,
245
+ "current_page": page,
246
+ "images": images,
247
+ }