fractal-server 2.6.3__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.
Files changed (72) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +1 -1
  3. fractal_server/app/models/linkusergroup.py +11 -0
  4. fractal_server/app/models/v2/__init__.py +2 -0
  5. fractal_server/app/models/v2/collection_state.py +1 -0
  6. fractal_server/app/models/v2/task.py +67 -2
  7. fractal_server/app/routes/admin/v2/__init__.py +16 -0
  8. fractal_server/app/routes/admin/{v2.py → v2/job.py} +20 -191
  9. fractal_server/app/routes/admin/v2/project.py +43 -0
  10. fractal_server/app/routes/admin/v2/task.py +133 -0
  11. fractal_server/app/routes/admin/v2/task_group.py +162 -0
  12. fractal_server/app/routes/api/v1/task_collection.py +4 -4
  13. fractal_server/app/routes/api/v2/__init__.py +8 -0
  14. fractal_server/app/routes/api/v2/_aux_functions.py +1 -68
  15. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +343 -0
  16. fractal_server/app/routes/api/v2/submit.py +16 -35
  17. fractal_server/app/routes/api/v2/task.py +85 -110
  18. fractal_server/app/routes/api/v2/task_collection.py +184 -196
  19. fractal_server/app/routes/api/v2/task_collection_custom.py +70 -64
  20. fractal_server/app/routes/api/v2/task_group.py +173 -0
  21. fractal_server/app/routes/api/v2/workflow.py +39 -102
  22. fractal_server/app/routes/api/v2/workflow_import.py +360 -0
  23. fractal_server/app/routes/api/v2/workflowtask.py +4 -8
  24. fractal_server/app/routes/auth/_aux_auth.py +86 -40
  25. fractal_server/app/routes/auth/current_user.py +5 -5
  26. fractal_server/app/routes/auth/group.py +73 -23
  27. fractal_server/app/routes/auth/router.py +0 -2
  28. fractal_server/app/routes/auth/users.py +8 -7
  29. fractal_server/app/runner/executors/slurm/ssh/executor.py +82 -63
  30. fractal_server/app/runner/v2/__init__.py +13 -7
  31. fractal_server/app/runner/v2/task_interface.py +4 -9
  32. fractal_server/app/schemas/user.py +1 -2
  33. fractal_server/app/schemas/v2/__init__.py +7 -0
  34. fractal_server/app/schemas/v2/dataset.py +2 -7
  35. fractal_server/app/schemas/v2/dumps.py +1 -2
  36. fractal_server/app/schemas/v2/job.py +1 -1
  37. fractal_server/app/schemas/v2/manifest.py +25 -1
  38. fractal_server/app/schemas/v2/project.py +1 -1
  39. fractal_server/app/schemas/v2/task.py +95 -36
  40. fractal_server/app/schemas/v2/task_collection.py +8 -6
  41. fractal_server/app/schemas/v2/task_group.py +85 -0
  42. fractal_server/app/schemas/v2/workflow.py +7 -2
  43. fractal_server/app/schemas/v2/workflowtask.py +9 -6
  44. fractal_server/app/security/__init__.py +8 -1
  45. fractal_server/config.py +8 -28
  46. fractal_server/data_migrations/2_7_0.py +323 -0
  47. fractal_server/images/models.py +2 -4
  48. fractal_server/main.py +1 -1
  49. fractal_server/migrations/env.py +4 -1
  50. fractal_server/migrations/versions/034a469ec2eb_task_groups.py +184 -0
  51. fractal_server/ssh/_fabric.py +186 -73
  52. fractal_server/string_tools.py +6 -2
  53. fractal_server/tasks/utils.py +19 -5
  54. fractal_server/tasks/v1/_TaskCollectPip.py +1 -1
  55. fractal_server/tasks/v1/background_operations.py +5 -5
  56. fractal_server/tasks/v1/get_collection_data.py +2 -2
  57. fractal_server/tasks/v2/_venv_pip.py +67 -70
  58. fractal_server/tasks/v2/background_operations.py +180 -69
  59. fractal_server/tasks/v2/background_operations_ssh.py +57 -70
  60. fractal_server/tasks/v2/database_operations.py +44 -0
  61. fractal_server/tasks/v2/endpoint_operations.py +104 -116
  62. fractal_server/tasks/v2/templates/_1_create_venv.sh +9 -5
  63. fractal_server/tasks/v2/templates/{_2_upgrade_pip.sh → _2_preliminary_pip_operations.sh} +1 -0
  64. fractal_server/tasks/v2/utils.py +5 -0
  65. fractal_server/utils.py +3 -2
  66. {fractal_server-2.6.3.dist-info → fractal_server-2.7.0.dist-info}/METADATA +3 -7
  67. {fractal_server-2.6.3.dist-info → fractal_server-2.7.0.dist-info}/RECORD +70 -61
  68. fractal_server/app/routes/auth/group_names.py +0 -34
  69. fractal_server/tasks/v2/_TaskCollectPip.py +0 -132
  70. {fractal_server-2.6.3.dist-info → fractal_server-2.7.0.dist-info}/LICENSE +0 -0
  71. {fractal_server-2.6.3.dist-info → fractal_server-2.7.0.dist-info}/WHEEL +0 -0
  72. {fractal_server-2.6.3.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 not workflow.task_list:
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 .....logger import set_logger
12
- from ....db import AsyncSession
13
- from ....db import get_async_db
14
- from ....models.v1 import Task as TaskV1
15
- from ....models.v2 import TaskV2
16
- from ....models.v2 import WorkflowTaskV2
17
- from ....models.v2 import WorkflowV2
18
- from ....schemas.v2 import TaskCreateV2
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 = select(TaskV2)
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 db.get(TaskV2, task_id)
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 superusers and task owner)
106
+ Edit a specific task (restricted to task owner)
83
107
  """
84
108
 
85
109
  # Retrieve task from database
86
- db_task = await _get_task_check_owner(task_id=task_id, user=user, db=db)
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(), owner=owner, type=task_type)
189
- db.add(db_task)
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
- db_task = await _get_task_check_owner(task_id=task_id, user=user, db=db)
207
-
208
- # Check that the TaskV2 is not in relationship with some WorkflowTaskV2
209
- stm = select(WorkflowTaskV2).filter(WorkflowTaskV2.task_id == task_id)
210
- res = await db.execute(stm)
211
- workflow_tasks = res.scalars().all()
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
+ )