fractal-server 2.17.2__py3-none-any.whl → 2.18.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 +2 -1
- fractal_server/app/models/linkuserproject.py +40 -0
- fractal_server/app/models/security.py +7 -5
- fractal_server/app/models/v2/job.py +13 -2
- fractal_server/app/models/v2/resource.py +13 -0
- fractal_server/app/routes/admin/v2/__init__.py +11 -11
- fractal_server/app/routes/admin/v2/accounting.py +2 -2
- fractal_server/app/routes/admin/v2/job.py +34 -23
- fractal_server/app/routes/admin/v2/sharing.py +103 -0
- fractal_server/app/routes/admin/v2/task.py +9 -8
- fractal_server/app/routes/admin/v2/task_group.py +94 -16
- fractal_server/app/routes/admin/v2/task_group_lifecycle.py +20 -20
- fractal_server/app/routes/api/__init__.py +0 -9
- fractal_server/app/routes/api/v2/__init__.py +47 -47
- fractal_server/app/routes/api/v2/_aux_functions.py +65 -64
- fractal_server/app/routes/api/v2/_aux_functions_history.py +8 -3
- fractal_server/app/routes/api/v2/_aux_functions_sharing.py +97 -0
- fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +4 -4
- fractal_server/app/routes/api/v2/_aux_functions_tasks.py +2 -2
- fractal_server/app/routes/api/v2/dataset.py +89 -77
- fractal_server/app/routes/api/v2/history.py +28 -16
- fractal_server/app/routes/api/v2/images.py +22 -8
- fractal_server/app/routes/api/v2/job.py +40 -24
- fractal_server/app/routes/api/v2/pre_submission_checks.py +13 -6
- fractal_server/app/routes/api/v2/project.py +48 -25
- fractal_server/app/routes/api/v2/sharing.py +311 -0
- fractal_server/app/routes/api/v2/status_legacy.py +22 -33
- fractal_server/app/routes/api/v2/submit.py +76 -71
- fractal_server/app/routes/api/v2/task.py +15 -17
- fractal_server/app/routes/api/v2/task_collection.py +18 -18
- fractal_server/app/routes/api/v2/task_collection_custom.py +11 -13
- fractal_server/app/routes/api/v2/task_collection_pixi.py +9 -9
- fractal_server/app/routes/api/v2/task_group.py +18 -18
- fractal_server/app/routes/api/v2/task_group_lifecycle.py +26 -26
- fractal_server/app/routes/api/v2/task_version_update.py +12 -9
- fractal_server/app/routes/api/v2/workflow.py +41 -29
- fractal_server/app/routes/api/v2/workflow_import.py +25 -23
- fractal_server/app/routes/api/v2/workflowtask.py +25 -17
- fractal_server/app/routes/auth/_aux_auth.py +100 -0
- fractal_server/app/routes/auth/current_user.py +0 -63
- fractal_server/app/routes/auth/group.py +1 -30
- fractal_server/app/routes/auth/router.py +2 -0
- fractal_server/app/routes/auth/users.py +9 -0
- fractal_server/app/routes/auth/viewer_paths.py +43 -0
- fractal_server/app/schemas/user.py +29 -12
- fractal_server/app/schemas/user_group.py +0 -15
- fractal_server/app/schemas/v2/__init__.py +55 -48
- fractal_server/app/schemas/v2/dataset.py +35 -13
- fractal_server/app/schemas/v2/dumps.py +9 -9
- fractal_server/app/schemas/v2/job.py +11 -11
- fractal_server/app/schemas/v2/project.py +3 -3
- fractal_server/app/schemas/v2/resource.py +13 -4
- fractal_server/app/schemas/v2/sharing.py +99 -0
- fractal_server/app/schemas/v2/status_legacy.py +3 -3
- fractal_server/app/schemas/v2/task.py +6 -6
- fractal_server/app/schemas/v2/task_collection.py +4 -4
- fractal_server/app/schemas/v2/task_group.py +16 -16
- fractal_server/app/schemas/v2/workflow.py +16 -16
- fractal_server/app/schemas/v2/workflowtask.py +14 -14
- fractal_server/app/security/__init__.py +1 -1
- fractal_server/app/shutdown.py +6 -6
- fractal_server/config/__init__.py +0 -6
- fractal_server/config/_data.py +0 -79
- fractal_server/config/_main.py +6 -1
- fractal_server/data_migrations/2_18_0.py +30 -0
- fractal_server/images/models.py +1 -2
- fractal_server/main.py +72 -11
- fractal_server/migrations/versions/7910eed4cf97_user_project_dirs_and_usergroup_viewer_.py +60 -0
- fractal_server/migrations/versions/88270f589c9b_add_prevent_new_submissions.py +39 -0
- fractal_server/migrations/versions/bc0e8b3327a7_project_sharing.py +72 -0
- fractal_server/migrations/versions/f0702066b007_one_submitted_job_per_dataset.py +40 -0
- fractal_server/runner/config/_slurm.py +2 -0
- fractal_server/runner/executors/slurm_common/_batching.py +4 -10
- fractal_server/runner/executors/slurm_common/slurm_config.py +1 -0
- fractal_server/runner/executors/slurm_ssh/runner.py +1 -1
- fractal_server/runner/executors/slurm_sudo/runner.py +1 -1
- fractal_server/runner/v2/_local.py +4 -3
- fractal_server/runner/v2/_slurm_ssh.py +4 -3
- fractal_server/runner/v2/_slurm_sudo.py +4 -3
- fractal_server/runner/v2/runner.py +36 -17
- fractal_server/runner/v2/runner_functions.py +11 -14
- fractal_server/runner/v2/submit_workflow.py +22 -9
- fractal_server/tasks/v2/local/_utils.py +2 -2
- fractal_server/tasks/v2/local/collect.py +5 -6
- fractal_server/tasks/v2/local/collect_pixi.py +5 -6
- fractal_server/tasks/v2/local/deactivate.py +7 -7
- fractal_server/tasks/v2/local/deactivate_pixi.py +3 -3
- fractal_server/tasks/v2/local/delete.py +5 -5
- fractal_server/tasks/v2/local/reactivate.py +5 -5
- fractal_server/tasks/v2/local/reactivate_pixi.py +5 -5
- fractal_server/tasks/v2/ssh/collect.py +5 -5
- fractal_server/tasks/v2/ssh/collect_pixi.py +5 -5
- fractal_server/tasks/v2/ssh/deactivate.py +7 -7
- fractal_server/tasks/v2/ssh/deactivate_pixi.py +2 -2
- fractal_server/tasks/v2/ssh/delete.py +5 -5
- fractal_server/tasks/v2/ssh/reactivate.py +5 -5
- fractal_server/tasks/v2/ssh/reactivate_pixi.py +5 -5
- fractal_server/tasks/v2/utils_background.py +7 -7
- fractal_server/tasks/v2/utils_database.py +5 -5
- fractal_server/types/__init__.py +22 -0
- fractal_server/types/validators/__init__.py +3 -0
- fractal_server/types/validators/_common_validators.py +32 -0
- {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/METADATA +3 -2
- {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/RECORD +108 -98
- {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/WHEEL +0 -0
- {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/entry_points.txt +0 -0
- {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -19,15 +19,16 @@ from fractal_server.app.routes.auth import current_user_act_ver_prof
|
|
|
19
19
|
from fractal_server.app.routes.auth._aux_auth import (
|
|
20
20
|
_get_default_usergroup_id_or_none,
|
|
21
21
|
)
|
|
22
|
-
from fractal_server.app.schemas.v2 import
|
|
23
|
-
from fractal_server.app.schemas.v2 import
|
|
24
|
-
from fractal_server.app.schemas.v2 import
|
|
25
|
-
from fractal_server.app.schemas.v2 import
|
|
26
|
-
from fractal_server.app.schemas.v2 import
|
|
22
|
+
from fractal_server.app.schemas.v2 import TaskImport
|
|
23
|
+
from fractal_server.app.schemas.v2 import TaskImportLegacy
|
|
24
|
+
from fractal_server.app.schemas.v2 import WorkflowImport
|
|
25
|
+
from fractal_server.app.schemas.v2 import WorkflowReadWithWarnings
|
|
26
|
+
from fractal_server.app.schemas.v2 import WorkflowTaskCreate
|
|
27
|
+
from fractal_server.app.schemas.v2.sharing import ProjectPermissions
|
|
27
28
|
from fractal_server.logger import set_logger
|
|
28
29
|
|
|
29
30
|
from ._aux_functions import _check_workflow_exists
|
|
30
|
-
from ._aux_functions import
|
|
31
|
+
from ._aux_functions import _get_project_check_access
|
|
31
32
|
from ._aux_functions import _get_user_resource_id
|
|
32
33
|
from ._aux_functions import _workflow_insert_task
|
|
33
34
|
from ._aux_functions_tasks import _add_warnings_to_workflow_tasks
|
|
@@ -65,7 +66,7 @@ async def _get_user_accessible_taskgroups(
|
|
|
65
66
|
)
|
|
66
67
|
res = await db.execute(stm)
|
|
67
68
|
accessible_task_groups = res.scalars().all()
|
|
68
|
-
logger.
|
|
69
|
+
logger.debug(
|
|
69
70
|
f"Found {len(accessible_task_groups)} accessible "
|
|
70
71
|
f"task groups for {user_id=}."
|
|
71
72
|
)
|
|
@@ -100,7 +101,7 @@ async def _get_task_by_source(
|
|
|
100
101
|
|
|
101
102
|
async def _get_task_by_taskimport(
|
|
102
103
|
*,
|
|
103
|
-
task_import:
|
|
104
|
+
task_import: TaskImport,
|
|
104
105
|
task_groups_list: list[TaskGroupV2],
|
|
105
106
|
user_id: int,
|
|
106
107
|
default_group_id: int | None,
|
|
@@ -120,7 +121,7 @@ async def _get_task_by_taskimport(
|
|
|
120
121
|
`id` of the matching task, or `None`.
|
|
121
122
|
"""
|
|
122
123
|
|
|
123
|
-
logger.
|
|
124
|
+
logger.debug(f"[_get_task_by_taskimport] START, {task_import=}")
|
|
124
125
|
|
|
125
126
|
# Filter by `pkg_name` and by presence of a task with given `name`.
|
|
126
127
|
matching_task_groups = [
|
|
@@ -132,7 +133,7 @@ async def _get_task_by_taskimport(
|
|
|
132
133
|
)
|
|
133
134
|
]
|
|
134
135
|
if len(matching_task_groups) < 1:
|
|
135
|
-
logger.
|
|
136
|
+
logger.debug(
|
|
136
137
|
"[_get_task_by_taskimport] "
|
|
137
138
|
f"No task group with {task_import.pkg_name=} "
|
|
138
139
|
f"and a task with {task_import.name=}."
|
|
@@ -142,13 +143,13 @@ async def _get_task_by_taskimport(
|
|
|
142
143
|
# Determine target `version`
|
|
143
144
|
# Note that task_import.version cannot be "", due to a validator
|
|
144
145
|
if task_import.version is None:
|
|
145
|
-
logger.
|
|
146
|
+
logger.debug(
|
|
146
147
|
"[_get_task_by_taskimport] "
|
|
147
148
|
"No version requested, looking for latest."
|
|
148
149
|
)
|
|
149
150
|
latest_task = max(matching_task_groups, key=lambda tg: tg.version or "")
|
|
150
151
|
version = latest_task.version
|
|
151
|
-
logger.
|
|
152
|
+
logger.debug(
|
|
152
153
|
f"[_get_task_by_taskimport] Latest version set to {version}."
|
|
153
154
|
)
|
|
154
155
|
else:
|
|
@@ -160,19 +161,19 @@ async def _get_task_by_taskimport(
|
|
|
160
161
|
)
|
|
161
162
|
|
|
162
163
|
if len(final_matching_task_groups) < 1:
|
|
163
|
-
logger.
|
|
164
|
+
logger.debug(
|
|
164
165
|
"[_get_task_by_taskimport] "
|
|
165
166
|
"No task group left after filtering by version."
|
|
166
167
|
)
|
|
167
168
|
return None
|
|
168
169
|
elif len(final_matching_task_groups) == 1:
|
|
169
170
|
final_task_group = final_matching_task_groups[0]
|
|
170
|
-
logger.
|
|
171
|
+
logger.debug(
|
|
171
172
|
"[_get_task_by_taskimport] "
|
|
172
173
|
"Found a single task group, after filtering by version."
|
|
173
174
|
)
|
|
174
175
|
else:
|
|
175
|
-
logger.
|
|
176
|
+
logger.debug(
|
|
176
177
|
"[_get_task_by_taskimport] "
|
|
177
178
|
f"Found {len(final_matching_task_groups)} task groups, "
|
|
178
179
|
"after filtering by version."
|
|
@@ -184,7 +185,7 @@ async def _get_task_by_taskimport(
|
|
|
184
185
|
default_group_id=default_group_id,
|
|
185
186
|
)
|
|
186
187
|
if final_task_group is None:
|
|
187
|
-
logger.
|
|
188
|
+
logger.debug(
|
|
188
189
|
"[_get_task_by_taskimport] Disambiguation returned None."
|
|
189
190
|
)
|
|
190
191
|
return None
|
|
@@ -199,22 +200,22 @@ async def _get_task_by_taskimport(
|
|
|
199
200
|
None,
|
|
200
201
|
)
|
|
201
202
|
|
|
202
|
-
logger.
|
|
203
|
+
logger.debug(f"[_get_task_by_taskimport] END, {task_import=}, {task_id=}.")
|
|
203
204
|
|
|
204
205
|
return task_id
|
|
205
206
|
|
|
206
207
|
|
|
207
208
|
@router.post(
|
|
208
209
|
"/project/{project_id}/workflow/import/",
|
|
209
|
-
response_model=
|
|
210
|
+
response_model=WorkflowReadWithWarnings,
|
|
210
211
|
status_code=status.HTTP_201_CREATED,
|
|
211
212
|
)
|
|
212
213
|
async def import_workflow(
|
|
213
214
|
project_id: int,
|
|
214
|
-
workflow_import:
|
|
215
|
+
workflow_import: WorkflowImport,
|
|
215
216
|
user: UserOAuth = Depends(current_user_act_ver_prof),
|
|
216
217
|
db: AsyncSession = Depends(get_async_db),
|
|
217
|
-
) ->
|
|
218
|
+
) -> WorkflowReadWithWarnings:
|
|
218
219
|
"""
|
|
219
220
|
Import an existing workflow into a project and create required objects.
|
|
220
221
|
"""
|
|
@@ -222,9 +223,10 @@ async def import_workflow(
|
|
|
222
223
|
user_resource_id = await _get_user_resource_id(user_id=user.id, db=db)
|
|
223
224
|
|
|
224
225
|
# Preliminary checks
|
|
225
|
-
await
|
|
226
|
+
await _get_project_check_access(
|
|
226
227
|
project_id=project_id,
|
|
227
228
|
user_id=user.id,
|
|
229
|
+
required_permissions=ProjectPermissions.WRITE,
|
|
228
230
|
db=db,
|
|
229
231
|
)
|
|
230
232
|
await _check_workflow_exists(
|
|
@@ -244,7 +246,7 @@ async def import_workflow(
|
|
|
244
246
|
list_task_ids = []
|
|
245
247
|
for wf_task in workflow_import.task_list:
|
|
246
248
|
task_import = wf_task.task
|
|
247
|
-
if isinstance(task_import,
|
|
249
|
+
if isinstance(task_import, TaskImportLegacy):
|
|
248
250
|
task_id = await _get_task_by_source(
|
|
249
251
|
source=task_import.source,
|
|
250
252
|
task_groups_list=task_group_list,
|
|
@@ -262,7 +264,7 @@ async def import_workflow(
|
|
|
262
264
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
263
265
|
detail=f"Could not find a task matching with {wf_task.task}.",
|
|
264
266
|
)
|
|
265
|
-
new_wf_task =
|
|
267
|
+
new_wf_task = WorkflowTaskCreate(
|
|
266
268
|
**wf_task.model_dump(exclude_none=True, exclude={"task"})
|
|
267
269
|
)
|
|
268
270
|
list_wf_tasks.append(new_wf_task)
|
|
@@ -11,12 +11,13 @@ from fractal_server.app.db import get_async_db
|
|
|
11
11
|
from fractal_server.app.models import UserOAuth
|
|
12
12
|
from fractal_server.app.routes.auth import current_user_act_ver_prof
|
|
13
13
|
from fractal_server.app.schemas.v2 import TaskType
|
|
14
|
-
from fractal_server.app.schemas.v2 import
|
|
15
|
-
from fractal_server.app.schemas.v2 import
|
|
16
|
-
from fractal_server.app.schemas.v2 import
|
|
14
|
+
from fractal_server.app.schemas.v2 import WorkflowTaskCreate
|
|
15
|
+
from fractal_server.app.schemas.v2 import WorkflowTaskRead
|
|
16
|
+
from fractal_server.app.schemas.v2 import WorkflowTaskUpdate
|
|
17
|
+
from fractal_server.app.schemas.v2.sharing import ProjectPermissions
|
|
17
18
|
|
|
18
|
-
from ._aux_functions import
|
|
19
|
-
from ._aux_functions import
|
|
19
|
+
from ._aux_functions import _get_workflow_check_access
|
|
20
|
+
from ._aux_functions import _get_workflow_task_check_access
|
|
20
21
|
from ._aux_functions import _workflow_has_submitted_job
|
|
21
22
|
from ._aux_functions import _workflow_insert_task
|
|
22
23
|
from ._aux_functions_tasks import _check_type_filters_compatibility
|
|
@@ -27,23 +28,27 @@ router = APIRouter()
|
|
|
27
28
|
|
|
28
29
|
@router.post(
|
|
29
30
|
"/project/{project_id}/workflow/{workflow_id}/wftask/",
|
|
30
|
-
response_model=
|
|
31
|
+
response_model=WorkflowTaskRead,
|
|
31
32
|
status_code=status.HTTP_201_CREATED,
|
|
32
33
|
)
|
|
33
34
|
async def create_workflowtask(
|
|
34
35
|
project_id: int,
|
|
35
36
|
workflow_id: int,
|
|
36
37
|
task_id: int,
|
|
37
|
-
wftask:
|
|
38
|
+
wftask: WorkflowTaskCreate,
|
|
38
39
|
user: UserOAuth = Depends(current_user_act_ver_prof),
|
|
39
40
|
db: AsyncSession = Depends(get_async_db),
|
|
40
|
-
) ->
|
|
41
|
+
) -> WorkflowTaskRead | None:
|
|
41
42
|
"""
|
|
42
43
|
Add a WorkflowTask to a Workflow
|
|
43
44
|
"""
|
|
44
45
|
|
|
45
|
-
workflow = await
|
|
46
|
-
project_id=project_id,
|
|
46
|
+
workflow = await _get_workflow_check_access(
|
|
47
|
+
project_id=project_id,
|
|
48
|
+
workflow_id=workflow_id,
|
|
49
|
+
user_id=user.id,
|
|
50
|
+
required_permissions=ProjectPermissions.WRITE,
|
|
51
|
+
db=db,
|
|
47
52
|
)
|
|
48
53
|
|
|
49
54
|
task = await _get_task_read_access(
|
|
@@ -95,7 +100,7 @@ async def create_workflowtask(
|
|
|
95
100
|
|
|
96
101
|
@router.get(
|
|
97
102
|
"/project/{project_id}/workflow/{workflow_id}/wftask/{workflow_task_id}/",
|
|
98
|
-
response_model=
|
|
103
|
+
response_model=WorkflowTaskRead,
|
|
99
104
|
)
|
|
100
105
|
async def read_workflowtask(
|
|
101
106
|
project_id: int,
|
|
@@ -104,11 +109,12 @@ async def read_workflowtask(
|
|
|
104
109
|
user: UserOAuth = Depends(current_user_act_ver_prof),
|
|
105
110
|
db: AsyncSession = Depends(get_async_db),
|
|
106
111
|
):
|
|
107
|
-
workflow_task, _ = await
|
|
112
|
+
workflow_task, _ = await _get_workflow_task_check_access(
|
|
108
113
|
project_id=project_id,
|
|
109
114
|
workflow_task_id=workflow_task_id,
|
|
110
115
|
workflow_id=workflow_id,
|
|
111
116
|
user_id=user.id,
|
|
117
|
+
required_permissions=ProjectPermissions.READ,
|
|
112
118
|
db=db,
|
|
113
119
|
)
|
|
114
120
|
return workflow_task
|
|
@@ -116,25 +122,26 @@ async def read_workflowtask(
|
|
|
116
122
|
|
|
117
123
|
@router.patch(
|
|
118
124
|
"/project/{project_id}/workflow/{workflow_id}/wftask/{workflow_task_id}/",
|
|
119
|
-
response_model=
|
|
125
|
+
response_model=WorkflowTaskRead,
|
|
120
126
|
)
|
|
121
127
|
async def update_workflowtask(
|
|
122
128
|
project_id: int,
|
|
123
129
|
workflow_id: int,
|
|
124
130
|
workflow_task_id: int,
|
|
125
|
-
workflow_task_update:
|
|
131
|
+
workflow_task_update: WorkflowTaskUpdate,
|
|
126
132
|
user: UserOAuth = Depends(current_user_act_ver_prof),
|
|
127
133
|
db: AsyncSession = Depends(get_async_db),
|
|
128
|
-
) ->
|
|
134
|
+
) -> WorkflowTaskRead | None:
|
|
129
135
|
"""
|
|
130
136
|
Edit a WorkflowTask of a Workflow
|
|
131
137
|
"""
|
|
132
138
|
|
|
133
|
-
db_wf_task, db_workflow = await
|
|
139
|
+
db_wf_task, db_workflow = await _get_workflow_task_check_access(
|
|
134
140
|
project_id=project_id,
|
|
135
141
|
workflow_task_id=workflow_task_id,
|
|
136
142
|
workflow_id=workflow_id,
|
|
137
143
|
user_id=user.id,
|
|
144
|
+
required_permissions=ProjectPermissions.WRITE,
|
|
138
145
|
db=db,
|
|
139
146
|
)
|
|
140
147
|
if workflow_task_update.type_filters is not None:
|
|
@@ -215,11 +222,12 @@ async def delete_workflowtask(
|
|
|
215
222
|
Delete a WorkflowTask of a Workflow
|
|
216
223
|
"""
|
|
217
224
|
|
|
218
|
-
db_workflow_task, db_workflow = await
|
|
225
|
+
db_workflow_task, db_workflow = await _get_workflow_task_check_access(
|
|
219
226
|
project_id=project_id,
|
|
220
227
|
workflow_task_id=workflow_task_id,
|
|
221
228
|
workflow_id=workflow_id,
|
|
222
229
|
user_id=user.id,
|
|
230
|
+
required_permissions=ProjectPermissions.WRITE,
|
|
223
231
|
db=db,
|
|
224
232
|
)
|
|
225
233
|
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
from os.path import normpath
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
1
4
|
from fastapi import HTTPException
|
|
2
5
|
from fastapi import status
|
|
3
6
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlmodel import and_
|
|
4
8
|
from sqlmodel import asc
|
|
9
|
+
from sqlmodel import not_
|
|
10
|
+
from sqlmodel import or_
|
|
5
11
|
from sqlmodel import select
|
|
6
12
|
|
|
7
13
|
from fractal_server.app.models.linkusergroup import LinkUserGroup
|
|
14
|
+
from fractal_server.app.models.linkuserproject import LinkUserProjectV2
|
|
8
15
|
from fractal_server.app.models.security import UserGroup
|
|
9
16
|
from fractal_server.app.models.security import UserOAuth
|
|
17
|
+
from fractal_server.app.models.v2.dataset import DatasetV2
|
|
18
|
+
from fractal_server.app.models.v2.project import ProjectV2
|
|
10
19
|
from fractal_server.app.schemas.user import UserRead
|
|
11
20
|
from fractal_server.app.schemas.user_group import UserGroupRead
|
|
12
21
|
from fractal_server.config import get_settings
|
|
@@ -178,3 +187,94 @@ async def _verify_user_belongs_to_group(
|
|
|
178
187
|
f"to UserGroup {user_group_id}"
|
|
179
188
|
),
|
|
180
189
|
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _check_project_dirs_update(
|
|
193
|
+
*,
|
|
194
|
+
old_project_dirs: list[str],
|
|
195
|
+
new_project_dirs: list[str],
|
|
196
|
+
user_id: int,
|
|
197
|
+
db: AsyncSession,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Raises 422 if by replacing user's `project_dirs` with new ones we are
|
|
201
|
+
removing the access to a `zarr_dir` used by some dataset.
|
|
202
|
+
|
|
203
|
+
Note both `old_project_dirs` and `new_project_dirs` have been
|
|
204
|
+
normalized through `os.path.normpath`, which notably strips any trailing
|
|
205
|
+
`/` character. To be safe, we also re-normalize them within this function.
|
|
206
|
+
"""
|
|
207
|
+
# Create a list of all the old project dirs that will lose privileges.
|
|
208
|
+
# E.g.:
|
|
209
|
+
# old_project_dirs = ["/a", "/b", "/c/d", "/e/f"]
|
|
210
|
+
# new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"]
|
|
211
|
+
# removed_project_dirs == ["/b", "/e/f"]
|
|
212
|
+
removed_project_dirs = [
|
|
213
|
+
old_project_dir
|
|
214
|
+
for old_project_dir in old_project_dirs
|
|
215
|
+
if not any(
|
|
216
|
+
Path(old_project_dir).is_relative_to(new_project_dir)
|
|
217
|
+
for new_project_dir in new_project_dirs
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
if removed_project_dirs:
|
|
221
|
+
# Query all the `zarr_dir`s linked to the user such that `zarr_dir`
|
|
222
|
+
# starts with one of the project dirs in `removed_project_dirs`.
|
|
223
|
+
stmt = (
|
|
224
|
+
select(DatasetV2.zarr_dir)
|
|
225
|
+
.join(ProjectV2, ProjectV2.id == DatasetV2.project_id)
|
|
226
|
+
.join(
|
|
227
|
+
LinkUserProjectV2,
|
|
228
|
+
LinkUserProjectV2.project_id == ProjectV2.id,
|
|
229
|
+
)
|
|
230
|
+
.where(LinkUserProjectV2.user_id == user_id)
|
|
231
|
+
.where(LinkUserProjectV2.is_verified.is_(True))
|
|
232
|
+
.where(
|
|
233
|
+
or_(
|
|
234
|
+
*[
|
|
235
|
+
DatasetV2.zarr_dir.startswith(normpath(old_project_dir))
|
|
236
|
+
for old_project_dir in removed_project_dirs
|
|
237
|
+
]
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
if new_project_dirs:
|
|
242
|
+
stmt = stmt.where(
|
|
243
|
+
and_(
|
|
244
|
+
*[
|
|
245
|
+
not_(
|
|
246
|
+
DatasetV2.zarr_dir.startswith(
|
|
247
|
+
normpath(new_project_dir)
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
for new_project_dir in new_project_dirs
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
res = await db.execute(stmt)
|
|
255
|
+
|
|
256
|
+
# Raise 422 if one of the query results is relative to a path in
|
|
257
|
+
# `removed_project_dirs`, but its not relative to any path in
|
|
258
|
+
# `new_project_dirs`.
|
|
259
|
+
if any(
|
|
260
|
+
(
|
|
261
|
+
any(
|
|
262
|
+
Path(zarr_dir).is_relative_to(old_project_dir)
|
|
263
|
+
for old_project_dir in removed_project_dirs
|
|
264
|
+
)
|
|
265
|
+
and not any(
|
|
266
|
+
Path(zarr_dir).is_relative_to(new_project_dir)
|
|
267
|
+
for new_project_dir in new_project_dirs
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
for zarr_dir in res.scalars().all()
|
|
271
|
+
):
|
|
272
|
+
raise HTTPException(
|
|
273
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
274
|
+
detail=(
|
|
275
|
+
"You tried updating the user project_dirs, removing "
|
|
276
|
+
f"{removed_project_dirs}. This operation is not possible, "
|
|
277
|
+
"because it would make the user loose access to some of "
|
|
278
|
+
"their dataset zarr directories."
|
|
279
|
+
),
|
|
280
|
+
)
|
|
@@ -2,21 +2,16 @@
|
|
|
2
2
|
Definition of `/auth/current-user/` endpoints
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
5
|
from fastapi import APIRouter
|
|
8
6
|
from fastapi import Depends
|
|
9
7
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
8
|
from sqlmodel import select
|
|
11
9
|
|
|
12
10
|
from fractal_server.app.db import get_async_db
|
|
13
|
-
from fractal_server.app.models import LinkUserGroup
|
|
14
11
|
from fractal_server.app.models import Profile
|
|
15
12
|
from fractal_server.app.models import Resource
|
|
16
|
-
from fractal_server.app.models import UserGroup
|
|
17
13
|
from fractal_server.app.models import UserOAuth
|
|
18
14
|
from fractal_server.app.routes.auth import current_user_act
|
|
19
|
-
from fractal_server.app.routes.auth import current_user_act_ver
|
|
20
15
|
from fractal_server.app.routes.auth._aux_auth import (
|
|
21
16
|
_get_single_user_with_groups,
|
|
22
17
|
)
|
|
@@ -26,9 +21,6 @@ from fractal_server.app.schemas.user import UserUpdate
|
|
|
26
21
|
from fractal_server.app.schemas.user import UserUpdateStrict
|
|
27
22
|
from fractal_server.app.security import UserManager
|
|
28
23
|
from fractal_server.app.security import get_user_manager
|
|
29
|
-
from fractal_server.config import DataAuthScheme
|
|
30
|
-
from fractal_server.config import get_data_settings
|
|
31
|
-
from fractal_server.syringe import Inject
|
|
32
24
|
|
|
33
25
|
router_current_user = APIRouter()
|
|
34
26
|
|
|
@@ -106,58 +98,3 @@ async def get_current_user_profile_info(
|
|
|
106
98
|
)
|
|
107
99
|
|
|
108
100
|
return response_data
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@router_current_user.get(
|
|
112
|
-
"/current-user/allowed-viewer-paths/", response_model=list[str]
|
|
113
|
-
)
|
|
114
|
-
async def get_current_user_allowed_viewer_paths(
|
|
115
|
-
current_user: UserOAuth = Depends(current_user_act_ver),
|
|
116
|
-
db: AsyncSession = Depends(get_async_db),
|
|
117
|
-
) -> list[str]:
|
|
118
|
-
"""
|
|
119
|
-
Returns the allowed viewer paths for current user, according to the
|
|
120
|
-
selected FRACTAL_DATA_AUTH_SCHEME
|
|
121
|
-
"""
|
|
122
|
-
|
|
123
|
-
data_settings = Inject(get_data_settings)
|
|
124
|
-
|
|
125
|
-
authorized_paths = []
|
|
126
|
-
|
|
127
|
-
if data_settings.FRACTAL_DATA_AUTH_SCHEME == DataAuthScheme.NONE:
|
|
128
|
-
return authorized_paths
|
|
129
|
-
|
|
130
|
-
# Append `project_dir` to the list of authorized paths
|
|
131
|
-
authorized_paths.append(current_user.project_dir)
|
|
132
|
-
|
|
133
|
-
# If auth scheme is "users-folders" and `slurm_user` is set,
|
|
134
|
-
# build and append the user folder
|
|
135
|
-
if (
|
|
136
|
-
data_settings.FRACTAL_DATA_AUTH_SCHEME == DataAuthScheme.USERS_FOLDERS
|
|
137
|
-
and current_user.profile_id is not None
|
|
138
|
-
):
|
|
139
|
-
profile = await db.get(Profile, current_user.profile_id)
|
|
140
|
-
if profile is not None and profile.username is not None:
|
|
141
|
-
base_folder = data_settings.FRACTAL_DATA_BASE_FOLDER
|
|
142
|
-
user_folder = os.path.join(base_folder, profile.username)
|
|
143
|
-
authorized_paths.append(user_folder)
|
|
144
|
-
|
|
145
|
-
if data_settings.FRACTAL_DATA_AUTH_SCHEME == DataAuthScheme.VIEWER_PATHS:
|
|
146
|
-
# Returns the union of `viewer_paths` for all user's groups
|
|
147
|
-
cmd = (
|
|
148
|
-
select(UserGroup.viewer_paths)
|
|
149
|
-
.join(LinkUserGroup, LinkUserGroup.group_id == UserGroup.id)
|
|
150
|
-
.where(LinkUserGroup.user_id == current_user.id)
|
|
151
|
-
)
|
|
152
|
-
res = await db.execute(cmd)
|
|
153
|
-
viewer_paths_nested = res.scalars().all()
|
|
154
|
-
|
|
155
|
-
# Flatten a nested object and make its elements unique
|
|
156
|
-
all_viewer_paths_set = {
|
|
157
|
-
path
|
|
158
|
-
for _viewer_paths in viewer_paths_nested
|
|
159
|
-
for path in _viewer_paths
|
|
160
|
-
}
|
|
161
|
-
authorized_paths.extend(all_viewer_paths_set)
|
|
162
|
-
|
|
163
|
-
return authorized_paths
|
|
@@ -16,7 +16,6 @@ from fractal_server.app.models import UserGroup
|
|
|
16
16
|
from fractal_server.app.models import UserOAuth
|
|
17
17
|
from fractal_server.app.schemas.user_group import UserGroupCreate
|
|
18
18
|
from fractal_server.app.schemas.user_group import UserGroupRead
|
|
19
|
-
from fractal_server.app.schemas.user_group import UserGroupUpdate
|
|
20
19
|
from fractal_server.config import get_settings
|
|
21
20
|
from fractal_server.logger import set_logger
|
|
22
21
|
from fractal_server.syringe import Inject
|
|
@@ -101,41 +100,13 @@ async def create_single_group(
|
|
|
101
100
|
)
|
|
102
101
|
|
|
103
102
|
# Create and return new group
|
|
104
|
-
new_group = UserGroup(
|
|
105
|
-
name=group_create.name, viewer_paths=group_create.viewer_paths
|
|
106
|
-
)
|
|
103
|
+
new_group = UserGroup(name=group_create.name)
|
|
107
104
|
db.add(new_group)
|
|
108
105
|
await db.commit()
|
|
109
106
|
|
|
110
107
|
return dict(new_group.model_dump(), user_ids=[])
|
|
111
108
|
|
|
112
109
|
|
|
113
|
-
@router_group.patch(
|
|
114
|
-
"/group/{group_id}/",
|
|
115
|
-
response_model=UserGroupRead,
|
|
116
|
-
status_code=status.HTTP_200_OK,
|
|
117
|
-
)
|
|
118
|
-
async def update_single_group(
|
|
119
|
-
group_id: int,
|
|
120
|
-
group_update: UserGroupUpdate,
|
|
121
|
-
user: UserOAuth = Depends(current_superuser_act),
|
|
122
|
-
db: AsyncSession = Depends(get_async_db),
|
|
123
|
-
) -> UserGroupRead:
|
|
124
|
-
group = await _usergroup_or_404(group_id, db)
|
|
125
|
-
|
|
126
|
-
# Patch `viewer_paths`
|
|
127
|
-
if group_update.viewer_paths is not None:
|
|
128
|
-
group.viewer_paths = group_update.viewer_paths
|
|
129
|
-
db.add(group)
|
|
130
|
-
await db.commit()
|
|
131
|
-
|
|
132
|
-
updated_group = await _get_single_usergroup_with_user_ids(
|
|
133
|
-
group_id=group_id, db=db
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
return updated_group
|
|
137
|
-
|
|
138
|
-
|
|
139
110
|
@router_group.delete("/group/{group_id}/", status_code=204)
|
|
140
111
|
async def delete_single_group(
|
|
141
112
|
group_id: int,
|
|
@@ -6,6 +6,7 @@ from .login import router_login
|
|
|
6
6
|
from .oauth import get_oauth_router
|
|
7
7
|
from .register import router_register
|
|
8
8
|
from .users import router_users
|
|
9
|
+
from .viewer_paths import router_viewer_paths
|
|
9
10
|
|
|
10
11
|
router_auth = APIRouter()
|
|
11
12
|
|
|
@@ -14,6 +15,7 @@ router_auth.include_router(router_current_user)
|
|
|
14
15
|
router_auth.include_router(router_login)
|
|
15
16
|
router_auth.include_router(router_users)
|
|
16
17
|
router_auth.include_router(router_group)
|
|
18
|
+
router_auth.include_router(router_viewer_paths)
|
|
17
19
|
router_oauth = get_oauth_router()
|
|
18
20
|
if router_oauth is not None:
|
|
19
21
|
router_auth.include_router(router_oauth)
|
|
@@ -28,6 +28,7 @@ from fractal_server.logger import set_logger
|
|
|
28
28
|
from fractal_server.syringe import Inject
|
|
29
29
|
|
|
30
30
|
from . import current_superuser_act
|
|
31
|
+
from ._aux_auth import _check_project_dirs_update
|
|
31
32
|
from ._aux_auth import _get_default_usergroup_id_or_none
|
|
32
33
|
from ._aux_auth import _get_single_user_with_groups
|
|
33
34
|
|
|
@@ -74,6 +75,14 @@ async def patch_user(
|
|
|
74
75
|
detail=f"Profile {user_update.profile_id} not found.",
|
|
75
76
|
)
|
|
76
77
|
|
|
78
|
+
if user_update.project_dirs is not None:
|
|
79
|
+
await _check_project_dirs_update(
|
|
80
|
+
old_project_dirs=user_to_patch.project_dirs,
|
|
81
|
+
new_project_dirs=user_update.project_dirs,
|
|
82
|
+
user_id=user_id,
|
|
83
|
+
db=db,
|
|
84
|
+
)
|
|
85
|
+
|
|
77
86
|
# Modify user attributes
|
|
78
87
|
try:
|
|
79
88
|
user = await user_manager.update(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from fastapi import Depends
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlmodel import select
|
|
5
|
+
|
|
6
|
+
from fractal_server.app.db import get_async_db
|
|
7
|
+
from fractal_server.app.models import UserOAuth
|
|
8
|
+
from fractal_server.app.models.linkuserproject import LinkUserProjectV2
|
|
9
|
+
from fractal_server.app.models.v2.dataset import DatasetV2
|
|
10
|
+
from fractal_server.app.models.v2.project import ProjectV2
|
|
11
|
+
from fractal_server.app.routes.auth import current_user_act_ver
|
|
12
|
+
|
|
13
|
+
router_viewer_paths = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router_viewer_paths.get(
|
|
17
|
+
"/current-user/allowed-viewer-paths/", response_model=list[str]
|
|
18
|
+
)
|
|
19
|
+
async def get_current_user_allowed_viewer_paths(
|
|
20
|
+
include_shared_projects: bool = True,
|
|
21
|
+
current_user: UserOAuth = Depends(current_user_act_ver),
|
|
22
|
+
db: AsyncSession = Depends(get_async_db),
|
|
23
|
+
) -> list[str]:
|
|
24
|
+
"""
|
|
25
|
+
Returns the allowed viewer paths for current user.
|
|
26
|
+
"""
|
|
27
|
+
authorized_paths = current_user.project_dirs.copy()
|
|
28
|
+
|
|
29
|
+
if include_shared_projects:
|
|
30
|
+
res = await db.execute(
|
|
31
|
+
select(DatasetV2.zarr_dir)
|
|
32
|
+
.join(ProjectV2, ProjectV2.id == DatasetV2.project_id)
|
|
33
|
+
.join(
|
|
34
|
+
LinkUserProjectV2, LinkUserProjectV2.project_id == ProjectV2.id
|
|
35
|
+
)
|
|
36
|
+
.where(LinkUserProjectV2.user_id == current_user.id)
|
|
37
|
+
.where(LinkUserProjectV2.is_verified.is_(True))
|
|
38
|
+
)
|
|
39
|
+
authorized_paths.extend(res.unique().scalars().all())
|
|
40
|
+
# Note that `project_dirs` and the `db.execute` result may have some
|
|
41
|
+
# common elements, and then this list may have non-unique items.
|
|
42
|
+
|
|
43
|
+
return authorized_paths
|