fractal-server 2.6.4__py3-none-any.whl → 2.7.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 +1 -1
- fractal_server/app/models/linkusergroup.py +11 -0
- fractal_server/app/models/v2/__init__.py +2 -0
- fractal_server/app/models/v2/collection_state.py +1 -0
- fractal_server/app/models/v2/task.py +67 -2
- fractal_server/app/routes/admin/v2/__init__.py +16 -0
- fractal_server/app/routes/admin/{v2.py → v2/job.py} +20 -191
- fractal_server/app/routes/admin/v2/project.py +43 -0
- fractal_server/app/routes/admin/v2/task.py +133 -0
- fractal_server/app/routes/admin/v2/task_group.py +162 -0
- fractal_server/app/routes/api/v1/task_collection.py +4 -4
- fractal_server/app/routes/api/v2/__init__.py +8 -0
- fractal_server/app/routes/api/v2/_aux_functions.py +1 -68
- fractal_server/app/routes/api/v2/_aux_functions_tasks.py +343 -0
- fractal_server/app/routes/api/v2/submit.py +16 -35
- fractal_server/app/routes/api/v2/task.py +85 -110
- fractal_server/app/routes/api/v2/task_collection.py +184 -196
- fractal_server/app/routes/api/v2/task_collection_custom.py +70 -64
- fractal_server/app/routes/api/v2/task_group.py +173 -0
- fractal_server/app/routes/api/v2/workflow.py +39 -102
- fractal_server/app/routes/api/v2/workflow_import.py +360 -0
- fractal_server/app/routes/api/v2/workflowtask.py +4 -8
- fractal_server/app/routes/auth/_aux_auth.py +86 -40
- fractal_server/app/routes/auth/current_user.py +5 -5
- fractal_server/app/routes/auth/group.py +73 -23
- fractal_server/app/routes/auth/router.py +0 -2
- fractal_server/app/routes/auth/users.py +8 -7
- fractal_server/app/runner/executors/slurm/ssh/executor.py +82 -63
- fractal_server/app/runner/v2/__init__.py +13 -7
- fractal_server/app/runner/v2/task_interface.py +4 -9
- fractal_server/app/schemas/user.py +1 -2
- fractal_server/app/schemas/v2/__init__.py +7 -0
- fractal_server/app/schemas/v2/dataset.py +2 -7
- fractal_server/app/schemas/v2/dumps.py +1 -2
- fractal_server/app/schemas/v2/job.py +1 -1
- fractal_server/app/schemas/v2/manifest.py +25 -1
- fractal_server/app/schemas/v2/project.py +1 -1
- fractal_server/app/schemas/v2/task.py +95 -36
- fractal_server/app/schemas/v2/task_collection.py +8 -6
- fractal_server/app/schemas/v2/task_group.py +85 -0
- fractal_server/app/schemas/v2/workflow.py +7 -2
- fractal_server/app/schemas/v2/workflowtask.py +9 -6
- fractal_server/app/security/__init__.py +8 -1
- fractal_server/config.py +8 -28
- fractal_server/data_migrations/2_7_0.py +323 -0
- fractal_server/images/models.py +2 -4
- fractal_server/main.py +1 -1
- fractal_server/migrations/versions/034a469ec2eb_task_groups.py +184 -0
- fractal_server/ssh/_fabric.py +186 -73
- fractal_server/string_tools.py +6 -2
- fractal_server/tasks/utils.py +19 -5
- fractal_server/tasks/v1/_TaskCollectPip.py +1 -1
- fractal_server/tasks/v1/background_operations.py +5 -5
- fractal_server/tasks/v1/get_collection_data.py +2 -2
- fractal_server/tasks/v2/_venv_pip.py +67 -70
- fractal_server/tasks/v2/background_operations.py +180 -69
- fractal_server/tasks/v2/background_operations_ssh.py +57 -70
- fractal_server/tasks/v2/database_operations.py +44 -0
- fractal_server/tasks/v2/endpoint_operations.py +104 -116
- fractal_server/tasks/v2/templates/_1_create_venv.sh +9 -5
- fractal_server/tasks/v2/templates/{_2_upgrade_pip.sh → _2_preliminary_pip_operations.sh} +1 -0
- fractal_server/tasks/v2/utils.py +5 -0
- fractal_server/utils.py +3 -2
- {fractal_server-2.6.4.dist-info → fractal_server-2.7.0.dist-info}/METADATA +3 -7
- {fractal_server-2.6.4.dist-info → fractal_server-2.7.0.dist-info}/RECORD +69 -60
- fractal_server/app/routes/auth/group_names.py +0 -34
- fractal_server/tasks/v2/_TaskCollectPip.py +0 -132
- {fractal_server-2.6.4.dist-info → fractal_server-2.7.0.dist-info}/LICENSE +0 -0
- {fractal_server-2.6.4.dist-info → fractal_server-2.7.0.dist-info}/WHEEL +0 -0
- {fractal_server-2.6.4.dist-info → fractal_server-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,5 @@
|
|
1
1
|
import os
|
2
2
|
from datetime import datetime
|
3
|
-
from datetime import timedelta
|
4
3
|
from datetime import timezone
|
5
4
|
from pathlib import Path
|
6
5
|
from typing import Optional
|
@@ -32,6 +31,9 @@ from ._aux_functions import _get_dataset_check_owner
|
|
32
31
|
from ._aux_functions import _get_workflow_check_owner
|
33
32
|
from ._aux_functions import clean_app_job_list_v2
|
34
33
|
from fractal_server.app.models import UserOAuth
|
34
|
+
from fractal_server.app.routes.api.v2._aux_functions_tasks import (
|
35
|
+
_get_task_read_access,
|
36
|
+
)
|
35
37
|
from fractal_server.app.routes.auth import current_active_verified_user
|
36
38
|
|
37
39
|
|
@@ -83,15 +85,14 @@ async def apply_workflow(
|
|
83
85
|
workflow = await _get_workflow_check_owner(
|
84
86
|
project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
|
85
87
|
)
|
86
|
-
|
87
|
-
if
|
88
|
+
num_tasks = len(workflow.task_list)
|
89
|
+
if num_tasks == 0:
|
88
90
|
raise HTTPException(
|
89
91
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
90
92
|
detail=f"Workflow {workflow_id} has empty task list",
|
91
93
|
)
|
92
94
|
|
93
95
|
# Set values of first_task_index and last_task_index
|
94
|
-
num_tasks = len(workflow.task_list)
|
95
96
|
try:
|
96
97
|
first_task_index, last_task_index = set_start_and_last_task_index(
|
97
98
|
num_tasks,
|
@@ -110,6 +111,17 @@ async def apply_workflow(
|
|
110
111
|
),
|
111
112
|
)
|
112
113
|
|
114
|
+
# Check that tasks have read-access and are `active`
|
115
|
+
for wftask in workflow.task_list[
|
116
|
+
first_task_index : last_task_index + 1 # noqa: E203
|
117
|
+
]:
|
118
|
+
await _get_task_read_access(
|
119
|
+
user_id=user.id,
|
120
|
+
task_id=wftask.task_id,
|
121
|
+
require_active=True,
|
122
|
+
db=db,
|
123
|
+
)
|
124
|
+
|
113
125
|
# Validate user settings
|
114
126
|
FRACTAL_RUNNER_BACKEND = settings.FRACTAL_RUNNER_BACKEND
|
115
127
|
user_settings = await validate_user_settings(
|
@@ -168,37 +180,6 @@ async def apply_workflow(
|
|
168
180
|
**job_create.dict(),
|
169
181
|
)
|
170
182
|
|
171
|
-
# Rate Limiting:
|
172
|
-
# raise `429 TOO MANY REQUESTS` if this endpoint has been called with the
|
173
|
-
# same database keys (Project, Workflow and Datasets) during the last
|
174
|
-
# `settings.FRACTAL_API_SUBMIT_RATE_LIMIT` seconds.
|
175
|
-
stm = (
|
176
|
-
select(JobV2)
|
177
|
-
.where(JobV2.project_id == project_id)
|
178
|
-
.where(JobV2.workflow_id == workflow_id)
|
179
|
-
.where(JobV2.dataset_id == dataset_id)
|
180
|
-
)
|
181
|
-
res = await db.execute(stm)
|
182
|
-
db_jobs = res.scalars().all()
|
183
|
-
if db_jobs and any(
|
184
|
-
abs(
|
185
|
-
job.start_timestamp
|
186
|
-
- db_job.start_timestamp.replace(tzinfo=timezone.utc)
|
187
|
-
)
|
188
|
-
< timedelta(seconds=settings.FRACTAL_API_SUBMIT_RATE_LIMIT)
|
189
|
-
for db_job in db_jobs
|
190
|
-
):
|
191
|
-
raise HTTPException(
|
192
|
-
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
193
|
-
detail=(
|
194
|
-
f"The endpoint 'POST /api/v2/project/{project_id}/job/submit/'"
|
195
|
-
" was called several times within an interval of less "
|
196
|
-
f"than {settings.FRACTAL_API_SUBMIT_RATE_LIMIT} seconds, using"
|
197
|
-
" the same foreign keys. If it was intentional, please wait "
|
198
|
-
"and try again."
|
199
|
-
),
|
200
|
-
)
|
201
|
-
|
202
183
|
db.add(job)
|
203
184
|
await db.commit()
|
204
185
|
await db.refresh(job)
|
@@ -6,23 +6,28 @@ from fastapi import Depends
|
|
6
6
|
from fastapi import HTTPException
|
7
7
|
from fastapi import Response
|
8
8
|
from fastapi import status
|
9
|
+
from sqlmodel import func
|
10
|
+
from sqlmodel import or_
|
9
11
|
from sqlmodel import select
|
10
12
|
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
15
|
-
from
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from ....schemas.v2 import TaskReadV2
|
20
|
-
from ....schemas.v2 import TaskUpdateV2
|
21
|
-
from ...aux.validate_user_settings import verify_user_has_settings
|
22
|
-
from ._aux_functions import _get_task_check_owner
|
13
|
+
from ._aux_functions_tasks import _get_task_full_access
|
14
|
+
from ._aux_functions_tasks import _get_task_read_access
|
15
|
+
from ._aux_functions_tasks import _get_valid_user_group_id
|
16
|
+
from ._aux_functions_tasks import _verify_non_duplication_group_constraint
|
17
|
+
from ._aux_functions_tasks import _verify_non_duplication_user_constraint
|
18
|
+
from fractal_server.app.db import AsyncSession
|
19
|
+
from fractal_server.app.db import get_async_db
|
20
|
+
from fractal_server.app.models import LinkUserGroup
|
23
21
|
from fractal_server.app.models import UserOAuth
|
22
|
+
from fractal_server.app.models.v2 import TaskGroupV2
|
23
|
+
from fractal_server.app.models.v2 import TaskV2
|
24
24
|
from fractal_server.app.routes.auth import current_active_user
|
25
25
|
from fractal_server.app.routes.auth import current_active_verified_user
|
26
|
+
from fractal_server.app.schemas.v2 import TaskCreateV2
|
27
|
+
from fractal_server.app.schemas.v2 import TaskGroupV2OriginEnum
|
28
|
+
from fractal_server.app.schemas.v2 import TaskReadV2
|
29
|
+
from fractal_server.app.schemas.v2 import TaskUpdateV2
|
30
|
+
from fractal_server.logger import set_logger
|
26
31
|
|
27
32
|
router = APIRouter()
|
28
33
|
|
@@ -33,13 +38,37 @@ logger = set_logger(__name__)
|
|
33
38
|
async def get_list_task(
|
34
39
|
args_schema_parallel: bool = True,
|
35
40
|
args_schema_non_parallel: bool = True,
|
41
|
+
category: Optional[str] = None,
|
42
|
+
modality: Optional[str] = None,
|
43
|
+
author: Optional[str] = None,
|
36
44
|
user: UserOAuth = Depends(current_active_user),
|
37
45
|
db: AsyncSession = Depends(get_async_db),
|
38
46
|
) -> list[TaskReadV2]:
|
39
47
|
"""
|
40
48
|
Get list of available tasks
|
41
49
|
"""
|
42
|
-
stm =
|
50
|
+
stm = (
|
51
|
+
select(TaskV2)
|
52
|
+
.join(TaskGroupV2)
|
53
|
+
.where(TaskGroupV2.id == TaskV2.taskgroupv2_id)
|
54
|
+
.where(
|
55
|
+
or_(
|
56
|
+
TaskGroupV2.user_id == user.id,
|
57
|
+
TaskGroupV2.user_group_id.in_(
|
58
|
+
select(LinkUserGroup.group_id).where(
|
59
|
+
LinkUserGroup.user_id == user.id
|
60
|
+
)
|
61
|
+
),
|
62
|
+
)
|
63
|
+
)
|
64
|
+
)
|
65
|
+
if category is not None:
|
66
|
+
stm = stm.where(func.lower(TaskV2.category) == category.lower())
|
67
|
+
if modality is not None:
|
68
|
+
stm = stm.where(func.lower(TaskV2.modality) == modality.lower())
|
69
|
+
if author is not None:
|
70
|
+
stm = stm.where(TaskV2.authors.icontains(author))
|
71
|
+
|
43
72
|
res = await db.execute(stm)
|
44
73
|
task_list = res.scalars().all()
|
45
74
|
await db.close()
|
@@ -62,12 +91,7 @@ async def get_task(
|
|
62
91
|
"""
|
63
92
|
Get info on a specific task
|
64
93
|
"""
|
65
|
-
task = await
|
66
|
-
await db.close()
|
67
|
-
if not task:
|
68
|
-
raise HTTPException(
|
69
|
-
status_code=status.HTTP_404_NOT_FOUND, detail="TaskV2 not found"
|
70
|
-
)
|
94
|
+
task = await _get_task_read_access(task_id=task_id, user_id=user.id, db=db)
|
71
95
|
return task
|
72
96
|
|
73
97
|
|
@@ -79,11 +103,13 @@ async def patch_task(
|
|
79
103
|
db: AsyncSession = Depends(get_async_db),
|
80
104
|
) -> Optional[TaskReadV2]:
|
81
105
|
"""
|
82
|
-
Edit a specific task (restricted to
|
106
|
+
Edit a specific task (restricted to task owner)
|
83
107
|
"""
|
84
108
|
|
85
109
|
# Retrieve task from database
|
86
|
-
db_task = await
|
110
|
+
db_task = await _get_task_full_access(
|
111
|
+
task_id=task_id, user_id=user.id, db=db
|
112
|
+
)
|
87
113
|
update = task_update.dict(exclude_unset=True)
|
88
114
|
|
89
115
|
# Forbid changes that set a previously unset command
|
@@ -112,6 +138,8 @@ async def patch_task(
|
|
112
138
|
)
|
113
139
|
async def create_task(
|
114
140
|
task: TaskCreateV2,
|
141
|
+
user_group_id: Optional[int] = None,
|
142
|
+
private: bool = False,
|
115
143
|
user: UserOAuth = Depends(current_active_verified_user),
|
116
144
|
db: AsyncSession = Depends(get_async_db),
|
117
145
|
) -> Optional[TaskReadV2]:
|
@@ -119,6 +147,14 @@ async def create_task(
|
|
119
147
|
Create a new task
|
120
148
|
"""
|
121
149
|
|
150
|
+
# Validate query parameters related to user-group ownership
|
151
|
+
user_group_id = await _get_valid_user_group_id(
|
152
|
+
user_group_id=user_group_id,
|
153
|
+
private=private,
|
154
|
+
user_id=user.id,
|
155
|
+
db=db,
|
156
|
+
)
|
157
|
+
|
122
158
|
if task.command_non_parallel is None:
|
123
159
|
task_type = "parallel"
|
124
160
|
elif task.command_parallel is None:
|
@@ -148,45 +184,28 @@ async def create_task(
|
|
148
184
|
),
|
149
185
|
)
|
150
186
|
|
151
|
-
# Set task.owner attribute
|
152
|
-
if user.username:
|
153
|
-
owner = user.username
|
154
|
-
else:
|
155
|
-
verify_user_has_settings(user)
|
156
|
-
if user.settings.slurm_user:
|
157
|
-
owner = user.settings.slurm_user
|
158
|
-
else:
|
159
|
-
raise HTTPException(
|
160
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
161
|
-
detail=(
|
162
|
-
"Cannot add a new task because current user does not "
|
163
|
-
"have `username` or `slurm_user` attributes."
|
164
|
-
),
|
165
|
-
)
|
166
|
-
|
167
|
-
# Prepend owner to task.source
|
168
|
-
task.source = f"{owner}:{task.source}"
|
169
|
-
|
170
|
-
# Verify that source is not already in use (note: this check is only useful
|
171
|
-
# to provide a user-friendly error message, but `task.source` uniqueness is
|
172
|
-
# already guaranteed by a constraint in the table definition).
|
173
|
-
stm = select(TaskV2).where(TaskV2.source == task.source)
|
174
|
-
res = await db.execute(stm)
|
175
|
-
if res.scalars().all():
|
176
|
-
raise HTTPException(
|
177
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
178
|
-
detail=f"Source '{task.source}' already used by some TaskV2",
|
179
|
-
)
|
180
|
-
stm = select(TaskV1).where(TaskV1.source == task.source)
|
181
|
-
res = await db.execute(stm)
|
182
|
-
if res.scalars().all():
|
183
|
-
raise HTTPException(
|
184
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
185
|
-
detail=f"Source '{task.source}' already used by some TaskV1",
|
186
|
-
)
|
187
187
|
# Add task
|
188
|
-
db_task = TaskV2(**task.dict(),
|
189
|
-
|
188
|
+
db_task = TaskV2(**task.dict(), type=task_type)
|
189
|
+
pkg_name = db_task.name
|
190
|
+
await _verify_non_duplication_user_constraint(
|
191
|
+
db=db, pkg_name=pkg_name, user_id=user.id, version=db_task.version
|
192
|
+
)
|
193
|
+
await _verify_non_duplication_group_constraint(
|
194
|
+
db=db,
|
195
|
+
pkg_name=pkg_name,
|
196
|
+
user_group_id=user_group_id,
|
197
|
+
version=db_task.version,
|
198
|
+
)
|
199
|
+
db_task_group = TaskGroupV2(
|
200
|
+
user_id=user.id,
|
201
|
+
user_group_id=user_group_id,
|
202
|
+
active=True,
|
203
|
+
task_list=[db_task],
|
204
|
+
origin=TaskGroupV2OriginEnum.OTHER,
|
205
|
+
version=db_task.version,
|
206
|
+
pkg_name=pkg_name,
|
207
|
+
)
|
208
|
+
db.add(db_task_group)
|
190
209
|
await db.commit()
|
191
210
|
await db.refresh(db_task)
|
192
211
|
await db.close()
|
@@ -202,54 +221,10 @@ async def delete_task(
|
|
202
221
|
"""
|
203
222
|
Delete a task
|
204
223
|
"""
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
if workflow_tasks:
|
214
|
-
# Find IDs of all affected workflows
|
215
|
-
workflow_ids = set(wftask.workflow_id for wftask in workflow_tasks)
|
216
|
-
# Fetch all affected workflows from DB
|
217
|
-
stm = select(WorkflowV2).where(WorkflowV2.id.in_(workflow_ids))
|
218
|
-
res = await db.execute(stm)
|
219
|
-
workflows = res.scalars().all()
|
220
|
-
|
221
|
-
# Find which workflows are associated to the current user
|
222
|
-
workflows_current_user = [
|
223
|
-
wf for wf in workflows if user in wf.project.user_list
|
224
|
-
]
|
225
|
-
if workflows_current_user:
|
226
|
-
current_user_msg = (
|
227
|
-
"For the current-user workflows (listed below),"
|
228
|
-
" you can update the task or remove the workflows.\n"
|
229
|
-
)
|
230
|
-
current_user_msg += "\n".join(
|
231
|
-
[
|
232
|
-
f"* '{wf.name}' (id={wf.id})"
|
233
|
-
for wf in workflows_current_user
|
234
|
-
]
|
235
|
-
)
|
236
|
-
else:
|
237
|
-
current_user_msg = ""
|
238
|
-
|
239
|
-
# Count workflows of current users or other users
|
240
|
-
num_workflows_current_user = len(workflows_current_user)
|
241
|
-
num_workflows_other_users = len(workflows) - num_workflows_current_user
|
242
|
-
|
243
|
-
raise HTTPException(
|
244
|
-
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
245
|
-
detail=(
|
246
|
-
f"Cannot remove Task with id={task_id}: it is currently in "
|
247
|
-
f"use in {num_workflows_current_user} current-user workflows "
|
248
|
-
f"and in {num_workflows_other_users} other-users workflows.\n"
|
249
|
-
f"{current_user_msg}"
|
250
|
-
),
|
251
|
-
)
|
252
|
-
|
253
|
-
await db.delete(db_task)
|
254
|
-
await db.commit()
|
255
|
-
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
224
|
+
raise HTTPException(
|
225
|
+
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
226
|
+
detail=(
|
227
|
+
"Cannot delete single tasks, "
|
228
|
+
"please operate directly on task groups."
|
229
|
+
),
|
230
|
+
)
|