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,7 +1,5 @@
1
- import json
2
1
  from pathlib import Path
3
- from shutil import copy as shell_copy
4
- from tempfile import TemporaryDirectory
2
+ from typing import Optional
5
3
 
6
4
  from fastapi import APIRouter
7
5
  from fastapi import BackgroundTasks
@@ -10,7 +8,7 @@ from fastapi import HTTPException
10
8
  from fastapi import Request
11
9
  from fastapi import Response
12
10
  from fastapi import status
13
- from pydantic.error_wrappers import ValidationError
11
+ from pydantic import ValidationError
14
12
  from sqlmodel import select
15
13
 
16
14
  from .....config import get_settings
@@ -20,26 +18,28 @@ from .....syringe import Inject
20
18
  from ....db import AsyncSession
21
19
  from ....db import get_async_db
22
20
  from ....models.v2 import CollectionStateV2
23
- from ....models.v2 import TaskV2
21
+ from ....models.v2 import TaskGroupV2
24
22
  from ....schemas.v2 import CollectionStateReadV2
25
23
  from ....schemas.v2 import CollectionStatusV2
26
24
  from ....schemas.v2 import TaskCollectPipV2
27
- from ....schemas.v2 import TaskReadV2
25
+ from ....schemas.v2 import TaskGroupCreateV2
28
26
  from ...aux.validate_user_settings import validate_user_settings
27
+ from ._aux_functions_tasks import _get_valid_user_group_id
28
+ from ._aux_functions_tasks import _verify_non_duplication_group_constraint
29
+ from ._aux_functions_tasks import _verify_non_duplication_user_constraint
29
30
  from fractal_server.app.models import UserOAuth
30
31
  from fractal_server.app.routes.auth import current_active_user
31
32
  from fractal_server.app.routes.auth import current_active_verified_user
32
- from fractal_server.string_tools import slugify_task_name_for_source
33
- from fractal_server.tasks.utils import get_absolute_venv_path
34
- from fractal_server.tasks.utils import get_collection_log
35
- from fractal_server.tasks.utils import get_collection_path
36
- from fractal_server.tasks.v2._TaskCollectPip import _TaskCollectPip
33
+ from fractal_server.app.schemas.v2 import TaskGroupV2OriginEnum
34
+ from fractal_server.tasks.utils import _normalize_package_name
35
+ from fractal_server.tasks.utils import get_collection_log_v2
37
36
  from fractal_server.tasks.v2.background_operations import (
38
37
  background_collect_pip,
39
38
  )
40
- from fractal_server.tasks.v2.endpoint_operations import create_package_dir_pip
41
- from fractal_server.tasks.v2.endpoint_operations import download_package
42
- from fractal_server.tasks.v2.endpoint_operations import inspect_package
39
+ from fractal_server.tasks.v2.endpoint_operations import (
40
+ get_package_version_from_pypi,
41
+ )
42
+ from fractal_server.tasks.v2.utils import _parse_wheel_filename
43
43
  from fractal_server.tasks.v2.utils import get_python_interpreter_v2
44
44
 
45
45
  router = APIRouter()
@@ -50,25 +50,14 @@ logger = set_logger(__name__)
50
50
  @router.post(
51
51
  "/collect/pip/",
52
52
  response_model=CollectionStateReadV2,
53
- responses={
54
- 201: dict(
55
- description=(
56
- "Task collection successfully started in the background"
57
- )
58
- ),
59
- 200: dict(
60
- description=(
61
- "Package already collected. Returning info on already "
62
- "available tasks"
63
- )
64
- ),
65
- },
66
53
  )
67
54
  async def collect_tasks_pip(
68
55
  task_collect: TaskCollectPipV2,
69
56
  background_tasks: BackgroundTasks,
70
57
  response: Response,
71
58
  request: Request,
59
+ private: bool = False,
60
+ user_group_id: Optional[int] = None,
72
61
  user: UserOAuth = Depends(current_active_verified_user),
73
62
  db: AsyncSession = Depends(get_async_db),
74
63
  ) -> CollectionStateReadV2:
@@ -82,209 +71,212 @@ async def collect_tasks_pip(
82
71
  # Get settings
83
72
  settings = Inject(get_settings)
84
73
 
74
+ # Initialize task-group attributes
75
+ task_group_attrs = dict(user_id=user.id)
76
+
85
77
  # Set/check python version
86
78
  if task_collect.python_version is None:
87
- task_collect.python_version = (
88
- settings.FRACTAL_TASKS_PYTHON_DEFAULT_VERSION
89
- )
79
+ task_group_attrs[
80
+ "python_version"
81
+ ] = settings.FRACTAL_TASKS_PYTHON_DEFAULT_VERSION
82
+ else:
83
+ task_group_attrs["python_version"] = task_collect.python_version
90
84
  try:
91
- get_python_interpreter_v2(python_version=task_collect.python_version)
85
+ get_python_interpreter_v2(
86
+ python_version=task_group_attrs["python_version"]
87
+ )
92
88
  except ValueError:
93
89
  raise HTTPException(
94
90
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
95
91
  detail=(
96
- f"Python version {task_collect.python_version} is "
92
+ f"Python version {task_group_attrs['python_version']} is "
97
93
  "not available for Fractal task collection."
98
94
  ),
99
95
  )
100
96
 
101
- # Validate payload
102
- try:
103
- task_pkg = _TaskCollectPip(**task_collect.dict(exclude_unset=True))
104
- except ValidationError as e:
105
- raise HTTPException(
106
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
107
- detail=f"Invalid task-collection object. Original error: {e}",
97
+ # Set pip_extras
98
+ if task_collect.package_extras is not None:
99
+ task_group_attrs["pip_extras"] = task_collect.package_extras
100
+
101
+ # Set pinned_package_versions
102
+ if task_collect.pinned_package_versions is not None:
103
+ task_group_attrs[
104
+ "pinned_package_versions"
105
+ ] = task_collect.pinned_package_versions
106
+
107
+ # Set pkg_name, version, origin and wheel_path
108
+ if task_collect.package.endswith(".whl"):
109
+ try:
110
+ task_group_attrs["wheel_path"] = task_collect.package
111
+ wheel_filename = Path(task_group_attrs["wheel_path"]).name
112
+ wheel_info = _parse_wheel_filename(wheel_filename)
113
+ except ValueError as e:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
116
+ detail=(
117
+ f"Invalid wheel-file name {wheel_filename}. "
118
+ f"Original error: {str(e)}",
119
+ ),
120
+ )
121
+ task_group_attrs["pkg_name"] = _normalize_package_name(
122
+ wheel_info["distribution"]
108
123
  )
124
+ task_group_attrs["version"] = wheel_info["version"]
125
+ task_group_attrs["origin"] = TaskGroupV2OriginEnum.WHEELFILE
126
+ else:
127
+ pkg_name = task_collect.package
128
+ task_group_attrs["pkg_name"] = _normalize_package_name(pkg_name)
129
+ task_group_attrs["origin"] = TaskGroupV2OriginEnum.PYPI
130
+ latest_version = await get_package_version_from_pypi(
131
+ task_collect.package,
132
+ task_collect.package_version,
133
+ )
134
+ task_group_attrs["version"] = latest_version
135
+
136
+ # Validate query parameters related to user-group ownership
137
+ user_group_id = await _get_valid_user_group_id(
138
+ user_group_id=user_group_id,
139
+ private=private,
140
+ user_id=user.id,
141
+ db=db,
142
+ )
143
+
144
+ # Set user_group_id
145
+ task_group_attrs["user_group_id"] = user_group_id
109
146
 
110
147
  # Validate user settings (backend-specific)
111
148
  user_settings = await validate_user_settings(
112
149
  user=user, backend=settings.FRACTAL_RUNNER_BACKEND, db=db
113
150
  )
114
151
 
115
- # END of SSH/non-SSH common part
116
-
152
+ # Set path and venv_path
117
153
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
118
-
119
- from fractal_server.tasks.v2.background_operations_ssh import (
120
- background_collect_pip_ssh,
154
+ base_tasks_path = user_settings.ssh_tasks_dir
155
+ else:
156
+ base_tasks_path = settings.FRACTAL_TASKS_DIR.as_posix()
157
+ task_group_path = (
158
+ Path(base_tasks_path)
159
+ / str(user.id)
160
+ / task_group_attrs["pkg_name"]
161
+ / task_group_attrs["version"]
162
+ ).as_posix()
163
+ task_group_attrs["path"] = task_group_path
164
+ task_group_attrs["venv_path"] = Path(task_group_path, "venv").as_posix()
165
+
166
+ # Validate TaskGroupV2 attributes
167
+ try:
168
+ TaskGroupCreateV2(**task_group_attrs)
169
+ except ValidationError as e:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
172
+ detail=f"Invalid task-group object. Original error: {e}",
121
173
  )
122
174
 
123
- # Construct and return state
124
- state = CollectionStateV2(
125
- data=dict(
126
- status=CollectionStatusV2.PENDING, package=task_collect.package
127
- )
128
- )
129
- db.add(state)
130
- await db.commit()
175
+ # Database checks
131
176
 
132
- # User appropriate FractalSSH object
133
- ssh_credentials = dict(
134
- user=user_settings.ssh_username,
135
- host=user_settings.ssh_host,
136
- key_path=user_settings.ssh_private_key_path,
137
- )
138
- fractal_ssh_list = request.app.state.fractal_ssh_list
139
- fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
177
+ # Verify non-duplication constraints
178
+ await _verify_non_duplication_user_constraint(
179
+ user_id=user.id,
180
+ pkg_name=task_group_attrs["pkg_name"],
181
+ version=task_group_attrs["version"],
182
+ db=db,
183
+ )
184
+ await _verify_non_duplication_group_constraint(
185
+ user_group_id=task_group_attrs["user_group_id"],
186
+ pkg_name=task_group_attrs["pkg_name"],
187
+ version=task_group_attrs["version"],
188
+ db=db,
189
+ )
140
190
 
141
- background_tasks.add_task(
142
- background_collect_pip_ssh,
143
- state.id,
144
- task_pkg,
145
- fractal_ssh,
146
- user_settings.ssh_tasks_dir,
191
+ # Verify that task-group path is unique
192
+ stm = select(TaskGroupV2).where(TaskGroupV2.path == task_group_path)
193
+ res = await db.execute(stm)
194
+ for conflicting_task_group in res.scalars().all():
195
+ raise HTTPException(
196
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
197
+ detail=(
198
+ f"Another task-group already has path={task_group_path}.\n"
199
+ f"{conflicting_task_group=}"
200
+ ),
147
201
  )
148
202
 
149
- response.status_code = status.HTTP_201_CREATED
150
- return state
151
-
152
- # Actual non-SSH endpoint
203
+ # On-disk checks
153
204
 
154
- logger = set_logger(logger_name="collect_tasks_pip")
205
+ if settings.FRACTAL_RUNNER_BACKEND != "slurm_ssh":
155
206
 
156
- with TemporaryDirectory() as tmpdir:
157
- try:
158
- # Copy or download the package wheel file to tmpdir
159
- if task_pkg.is_local_package:
160
- shell_copy(task_pkg.package_path.as_posix(), tmpdir)
161
- wheel_path = Path(tmpdir) / task_pkg.package_path.name
162
- else:
163
- logger.info(f"Now download {task_pkg}")
164
- wheel_path = await download_package(
165
- task_pkg=task_pkg, dest=tmpdir
166
- )
167
- # Read package info from wheel file, and override the ones coming
168
- # from the request body. Note that `package_name` was already set
169
- # (and normalized) as part of `_TaskCollectPip` initialization.
170
- pkg_info = inspect_package(wheel_path)
171
- task_pkg.package_version = pkg_info["pkg_version"]
172
- task_pkg.package_manifest = pkg_info["pkg_manifest"]
173
- except Exception as e:
207
+ # Verify that folder does not exist (for local collection)
208
+ if Path(task_group_path).exists():
174
209
  raise HTTPException(
175
210
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
176
- detail=f"Invalid package or manifest. Original error: {e}",
211
+ detail=f"{task_group_path} already exists.",
177
212
  )
178
213
 
179
- try:
180
- venv_path = create_package_dir_pip(task_pkg=task_pkg)
181
- except FileExistsError:
182
- venv_path = create_package_dir_pip(task_pkg=task_pkg, create=False)
183
- try:
184
- package_path = get_absolute_venv_path(venv_path)
185
- collection_path = get_collection_path(package_path)
186
- with collection_path.open("r") as f:
187
- task_collect_data = json.load(f)
188
-
189
- err_msg = (
190
- "Cannot collect package, possible reason: an old version of "
191
- "the same package has already been collected.\n"
192
- f"{str(collection_path)} has invalid content: "
193
- )
194
- if not isinstance(task_collect_data, dict):
195
- raise HTTPException(
196
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
197
- detail=f"{err_msg} it's not a Python dictionary.",
198
- )
199
- if "task_list" not in task_collect_data.keys():
200
- raise HTTPException(
201
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
202
- detail=f"{err_msg} it has no key 'task_list'.",
203
- )
204
- if not isinstance(task_collect_data["task_list"], list):
214
+ # Verify that wheel file exists
215
+ wheel_path = task_group_attrs.get("wheel_path", None)
216
+ if wheel_path is not None:
217
+ if not Path(wheel_path).exists():
205
218
  raise HTTPException(
206
219
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
207
- detail=f"{err_msg} 'task_list' is not a Python list.",
220
+ detail=f"No such file: {wheel_path}.",
208
221
  )
209
222
 
210
- for task_dict in task_collect_data["task_list"]:
211
-
212
- task = TaskReadV2(**task_dict)
213
- db_task = await db.get(TaskV2, task.id)
214
- if (
215
- (not db_task)
216
- or db_task.source != task.source
217
- or db_task.name != task.name
218
- ):
219
- await db.close()
220
- raise HTTPException(
221
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
222
- detail=(
223
- "Cannot collect package. Folder already exists, "
224
- f"but task {task.id} does not exists or it does "
225
- f"not have the expected source ({task.source}) or "
226
- f"name ({task.name})."
227
- ),
228
- )
229
- except FileNotFoundError as e:
230
- await db.close()
231
- raise HTTPException(
232
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
233
- detail=(
234
- "Cannot collect package. Possible reason: another "
235
- "collection of the same package is in progress. "
236
- f"Original FileNotFoundError: {e}"
237
- ),
238
- )
239
- except ValidationError as e:
240
- await db.close()
241
- raise HTTPException(
242
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
243
- detail=(
244
- "Cannot collect package. Possible reason: an old version "
245
- "of the same package has already been collected. "
246
- f"Original ValidationError: {e}"
247
- ),
248
- )
249
- task_collect_data["info"] = "Already installed"
250
- state = CollectionStateV2(data=task_collect_data)
251
- response.status_code == status.HTTP_200_OK
252
- await db.close()
253
- return state
254
- settings = Inject(get_settings)
255
-
256
- # Check that tasks are not already in the DB
257
- for new_task in task_pkg.package_manifest.task_list:
258
- new_task_name_slug = slugify_task_name_for_source(new_task.name)
259
- new_task_source = f"{task_pkg.package_source}:{new_task_name_slug}"
260
- stm = select(TaskV2).where(TaskV2.source == new_task_source)
261
- res = await db.execute(stm)
262
- if res.scalars().all():
263
- raise HTTPException(
264
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
265
- detail=(
266
- "Cannot collect package. Task with source "
267
- f'"{new_task_source}" already exists in the database.'
268
- ),
269
- )
223
+ # Create TaskGroupV2 object
224
+ task_group = TaskGroupV2(**task_group_attrs)
225
+ db.add(task_group)
226
+ await db.commit()
227
+ await db.refresh(task_group)
228
+ db.expunge(task_group)
270
229
 
271
230
  # All checks are OK, proceed with task collection
272
- collection_status = dict(
231
+ collection_state_data = dict(
273
232
  status=CollectionStatusV2.PENDING,
274
- venv_path=venv_path.relative_to(settings.FRACTAL_TASKS_DIR).as_posix(),
275
- package=task_pkg.package,
233
+ package=task_group.pkg_name,
234
+ version=task_group.version,
235
+ path=task_group.path,
236
+ venv_path=task_group.venv_path,
237
+ )
238
+ state = CollectionStateV2(
239
+ data=collection_state_data, taskgroupv2_id=task_group.id
276
240
  )
277
- state = CollectionStateV2(data=collection_status)
278
241
  db.add(state)
279
242
  await db.commit()
280
243
  await db.refresh(state)
281
244
 
282
- background_tasks.add_task(
283
- background_collect_pip,
284
- state_id=state.id,
285
- venv_path=venv_path,
286
- task_pkg=task_pkg,
287
- )
245
+ logger = set_logger(logger_name="collect_tasks_pip")
246
+
247
+ # END of SSH/non-SSH common part
248
+
249
+ if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
250
+ # SSH task collection
251
+
252
+ from fractal_server.tasks.v2.background_operations_ssh import (
253
+ background_collect_pip_ssh,
254
+ )
255
+
256
+ # User appropriate FractalSSH object
257
+ ssh_credentials = dict(
258
+ user=user_settings.ssh_username,
259
+ host=user_settings.ssh_host,
260
+ key_path=user_settings.ssh_private_key_path,
261
+ )
262
+ fractal_ssh_list = request.app.state.fractal_ssh_list
263
+ fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
264
+
265
+ background_tasks.add_task(
266
+ background_collect_pip_ssh,
267
+ state_id=state.id,
268
+ task_group=task_group,
269
+ fractal_ssh=fractal_ssh,
270
+ tasks_base_dir=user_settings.ssh_tasks_dir,
271
+ )
272
+
273
+ else:
274
+ # Local task collection
275
+ background_tasks.add_task(
276
+ background_collect_pip,
277
+ state_id=state.id,
278
+ task_group=task_group,
279
+ )
288
280
  logger.debug(
289
281
  "Task-collection endpoint: start background collection "
290
282
  "and return state"
@@ -292,11 +284,10 @@ async def collect_tasks_pip(
292
284
  reset_logger_handlers(logger)
293
285
  info = (
294
286
  "Collecting tasks in the background. "
295
- f"GET /task/collect/{state.id} to query collection status"
287
+ f"GET /task/collect/{state.id}/ to query collection status"
296
288
  )
297
289
  state.data["info"] = info
298
290
  response.status_code = status.HTTP_201_CREATED
299
- await db.close()
300
291
 
301
292
  return state
302
293
 
@@ -329,20 +320,17 @@ async def check_collection_status(
329
320
  else:
330
321
  # Non-SSH mode
331
322
  # In some cases (i.e. a successful or ongoing task collection),
332
- # state.data.log is not set; if so, we collect the current logs.
323
+ # state.data["log"] is not set; if so, we collect the current logs.
333
324
  if verbose and not state.data.get("log"):
334
- if "venv_path" not in state.data.keys():
325
+ if "path" not in state.data.keys():
335
326
  await db.close()
336
327
  raise HTTPException(
337
328
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
338
329
  detail=(
339
- f"No 'venv_path' in CollectionStateV2[{state_id}].data"
330
+ f"No 'path' in CollectionStateV2[{state_id}].data"
340
331
  ),
341
332
  )
342
- state.data["log"] = get_collection_log(
343
- Path(state.data["venv_path"])
344
- )
345
- state.data["venv_path"] = str(state.data["venv_path"])
333
+ state.data["log"] = get_collection_log_v2(Path(state.data["path"]))
346
334
 
347
335
  reset_logger_handlers(logger)
348
336
  await db.close()
@@ -1,31 +1,38 @@
1
1
  import shlex
2
2
  import subprocess # nosec
3
3
  from pathlib import Path
4
+ from typing import Optional
4
5
 
5
6
  from fastapi import APIRouter
6
7
  from fastapi import Depends
7
8
  from fastapi import HTTPException
8
9
  from fastapi import status
9
- from sqlmodel import select
10
-
11
- from .....config import get_settings
12
- from .....logger import set_logger
13
- from .....syringe import Inject
14
- from ....db import DBSyncSession
15
- from ....db import get_sync_db
16
- from ....models.v1 import Task as TaskV1
17
- from ....models.v2 import TaskV2
18
- from ....schemas.v2 import TaskCollectCustomV2
19
- from ....schemas.v2 import TaskCreateV2
20
- from ....schemas.v2 import TaskReadV2
21
- from ...aux.validate_user_settings import verify_user_has_settings
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from ._aux_functions_tasks import _get_valid_user_group_id
13
+ from ._aux_functions_tasks import _verify_non_duplication_group_constraint
14
+ from ._aux_functions_tasks import _verify_non_duplication_user_constraint
15
+ from fractal_server.app.db import DBSyncSession
16
+ from fractal_server.app.db import get_async_db
17
+ from fractal_server.app.db import get_sync_db
22
18
  from fractal_server.app.models import UserOAuth
19
+ from fractal_server.app.models.v2 import TaskGroupV2
23
20
  from fractal_server.app.routes.auth import current_active_verified_user
21
+ from fractal_server.app.schemas.v2 import TaskCollectCustomV2
22
+ from fractal_server.app.schemas.v2 import TaskCreateV2
23
+ from fractal_server.app.schemas.v2 import TaskGroupCreateV2
24
+ from fractal_server.app.schemas.v2 import TaskGroupV2OriginEnum
25
+ from fractal_server.app.schemas.v2 import TaskReadV2
26
+ from fractal_server.config import get_settings
27
+ from fractal_server.logger import set_logger
24
28
  from fractal_server.string_tools import validate_cmd
25
- from fractal_server.tasks.v2.background_operations import _insert_tasks
29
+ from fractal_server.syringe import Inject
26
30
  from fractal_server.tasks.v2.background_operations import (
27
31
  _prepare_tasks_metadata,
28
32
  )
33
+ from fractal_server.tasks.v2.database_operations import (
34
+ create_db_tasks_and_update_task_group,
35
+ )
29
36
 
30
37
  router = APIRouter()
31
38
 
@@ -37,12 +44,25 @@ logger = set_logger(__name__)
37
44
  )
38
45
  async def collect_task_custom(
39
46
  task_collect: TaskCollectCustomV2,
47
+ private: bool = False,
48
+ user_group_id: Optional[int] = None,
40
49
  user: UserOAuth = Depends(current_active_verified_user),
41
- db: DBSyncSession = Depends(get_sync_db),
50
+ db: AsyncSession = Depends(get_async_db), # FIXME: using both sync/async
51
+ db_sync: DBSyncSession = Depends(
52
+ get_sync_db
53
+ ), # FIXME: using both sync/async
42
54
  ) -> list[TaskReadV2]:
43
55
 
44
56
  settings = Inject(get_settings)
45
57
 
58
+ # Validate query parameters related to user-group ownership
59
+ user_group_id = await _get_valid_user_group_id(
60
+ user_group_id=user_group_id,
61
+ private=private,
62
+ user_id=user.id,
63
+ db=db,
64
+ )
65
+
46
66
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
47
67
  if task_collect.package_root is None:
48
68
  raise HTTPException(
@@ -112,65 +132,51 @@ async def collect_task_custom(
112
132
  else:
113
133
  package_root = Path(task_collect.package_root)
114
134
 
115
- # Set task.owner attribute
116
- if user.username:
117
- owner = user.username
118
- else:
119
- verify_user_has_settings(user)
120
- owner = user.settings.slurm_user
121
- if owner is None:
122
- raise HTTPException(
123
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
124
- detail=(
125
- "Cannot add a new task because current user does not "
126
- "have `username` or `slurm_user` attributes."
127
- ),
128
- )
129
- source = f"{owner}:{task_collect.source}"
130
-
131
135
  task_list: list[TaskCreateV2] = _prepare_tasks_metadata(
132
136
  package_manifest=task_collect.manifest,
133
- package_source=source,
134
137
  python_bin=Path(task_collect.python_interpreter),
135
138
  package_root=package_root,
136
139
  package_version=task_collect.version,
137
140
  )
138
- # Verify that source is not already in use (note: this check is only useful
139
- # to provide a user-friendly error message, but `task.source` uniqueness is
140
- # already guaranteed by a constraint in the table definition).
141
- sources = [task.source for task in task_list]
142
- stm = select(TaskV2).where(TaskV2.source.in_(sources))
143
- res = db.execute(stm)
144
- overlapping_sources_v2 = res.scalars().all()
145
- if overlapping_sources_v2:
146
- overlapping_tasks_v2_source_and_id = [
147
- f"TaskV2 with ID {task.id} already has source='{task.source}'"
148
- for task in overlapping_sources_v2
149
- ]
150
- raise HTTPException(
151
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
152
- detail="\n".join(overlapping_tasks_v2_source_and_id),
153
- )
154
- stm = select(TaskV1).where(TaskV1.source.in_(sources))
155
- res = db.execute(stm)
156
- overlapping_sources_v1 = res.scalars().all()
157
- if overlapping_sources_v1:
158
- overlapping_tasks_v1_source_and_id = [
159
- f"TaskV1 with ID {task.id} already has source='{task.source}'\n"
160
- for task in overlapping_sources_v1
161
- ]
162
- raise HTTPException(
163
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
164
- detail="\n".join(overlapping_tasks_v1_source_and_id),
165
- )
166
141
 
167
- task_list_db: list[TaskV2] = _insert_tasks(
168
- task_list=task_list, owner=owner, db=db
142
+ # Prepare task-group attributes
143
+ task_group_attrs = dict(
144
+ origin=TaskGroupV2OriginEnum.OTHER,
145
+ pkg_name=task_collect.label,
146
+ user_id=user.id,
147
+ user_group_id=user_group_id,
148
+ )
149
+ TaskGroupCreateV2(**task_group_attrs)
150
+
151
+ # Verify non-duplication constraints
152
+ await _verify_non_duplication_user_constraint(
153
+ user_id=user.id,
154
+ pkg_name=task_group_attrs["pkg_name"],
155
+ version=None,
156
+ db=db,
157
+ )
158
+ await _verify_non_duplication_group_constraint(
159
+ user_group_id=task_group_attrs["user_group_id"],
160
+ pkg_name=task_group_attrs["pkg_name"],
161
+ version=None,
162
+ db=db,
163
+ )
164
+
165
+ task_group = TaskGroupV2(**task_group_attrs)
166
+ db.add(task_group)
167
+ await db.commit()
168
+ await db.refresh(task_group)
169
+ db.expunge(task_group)
170
+
171
+ task_group = create_db_tasks_and_update_task_group(
172
+ task_list=task_list,
173
+ task_group_id=task_group.id,
174
+ db=db_sync,
169
175
  )
170
176
 
171
177
  logger.debug(
172
178
  f"Custom-environment task collection by user {user.email} completed, "
173
- f"for package with {source=}"
179
+ f"for package {task_collect}"
174
180
  )
175
181
 
176
- return task_list_db
182
+ return task_group.task_list