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.
Files changed (108) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +2 -1
  3. fractal_server/app/models/linkuserproject.py +40 -0
  4. fractal_server/app/models/security.py +7 -5
  5. fractal_server/app/models/v2/job.py +13 -2
  6. fractal_server/app/models/v2/resource.py +13 -0
  7. fractal_server/app/routes/admin/v2/__init__.py +11 -11
  8. fractal_server/app/routes/admin/v2/accounting.py +2 -2
  9. fractal_server/app/routes/admin/v2/job.py +34 -23
  10. fractal_server/app/routes/admin/v2/sharing.py +103 -0
  11. fractal_server/app/routes/admin/v2/task.py +9 -8
  12. fractal_server/app/routes/admin/v2/task_group.py +94 -16
  13. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +20 -20
  14. fractal_server/app/routes/api/__init__.py +0 -9
  15. fractal_server/app/routes/api/v2/__init__.py +47 -47
  16. fractal_server/app/routes/api/v2/_aux_functions.py +65 -64
  17. fractal_server/app/routes/api/v2/_aux_functions_history.py +8 -3
  18. fractal_server/app/routes/api/v2/_aux_functions_sharing.py +97 -0
  19. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +4 -4
  20. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +2 -2
  21. fractal_server/app/routes/api/v2/dataset.py +89 -77
  22. fractal_server/app/routes/api/v2/history.py +28 -16
  23. fractal_server/app/routes/api/v2/images.py +22 -8
  24. fractal_server/app/routes/api/v2/job.py +40 -24
  25. fractal_server/app/routes/api/v2/pre_submission_checks.py +13 -6
  26. fractal_server/app/routes/api/v2/project.py +48 -25
  27. fractal_server/app/routes/api/v2/sharing.py +311 -0
  28. fractal_server/app/routes/api/v2/status_legacy.py +22 -33
  29. fractal_server/app/routes/api/v2/submit.py +76 -71
  30. fractal_server/app/routes/api/v2/task.py +15 -17
  31. fractal_server/app/routes/api/v2/task_collection.py +18 -18
  32. fractal_server/app/routes/api/v2/task_collection_custom.py +11 -13
  33. fractal_server/app/routes/api/v2/task_collection_pixi.py +9 -9
  34. fractal_server/app/routes/api/v2/task_group.py +18 -18
  35. fractal_server/app/routes/api/v2/task_group_lifecycle.py +26 -26
  36. fractal_server/app/routes/api/v2/task_version_update.py +12 -9
  37. fractal_server/app/routes/api/v2/workflow.py +41 -29
  38. fractal_server/app/routes/api/v2/workflow_import.py +25 -23
  39. fractal_server/app/routes/api/v2/workflowtask.py +25 -17
  40. fractal_server/app/routes/auth/_aux_auth.py +100 -0
  41. fractal_server/app/routes/auth/current_user.py +0 -63
  42. fractal_server/app/routes/auth/group.py +1 -30
  43. fractal_server/app/routes/auth/router.py +2 -0
  44. fractal_server/app/routes/auth/users.py +9 -0
  45. fractal_server/app/routes/auth/viewer_paths.py +43 -0
  46. fractal_server/app/schemas/user.py +29 -12
  47. fractal_server/app/schemas/user_group.py +0 -15
  48. fractal_server/app/schemas/v2/__init__.py +55 -48
  49. fractal_server/app/schemas/v2/dataset.py +35 -13
  50. fractal_server/app/schemas/v2/dumps.py +9 -9
  51. fractal_server/app/schemas/v2/job.py +11 -11
  52. fractal_server/app/schemas/v2/project.py +3 -3
  53. fractal_server/app/schemas/v2/resource.py +13 -4
  54. fractal_server/app/schemas/v2/sharing.py +99 -0
  55. fractal_server/app/schemas/v2/status_legacy.py +3 -3
  56. fractal_server/app/schemas/v2/task.py +6 -6
  57. fractal_server/app/schemas/v2/task_collection.py +4 -4
  58. fractal_server/app/schemas/v2/task_group.py +16 -16
  59. fractal_server/app/schemas/v2/workflow.py +16 -16
  60. fractal_server/app/schemas/v2/workflowtask.py +14 -14
  61. fractal_server/app/security/__init__.py +1 -1
  62. fractal_server/app/shutdown.py +6 -6
  63. fractal_server/config/__init__.py +0 -6
  64. fractal_server/config/_data.py +0 -79
  65. fractal_server/config/_main.py +6 -1
  66. fractal_server/data_migrations/2_18_0.py +30 -0
  67. fractal_server/images/models.py +1 -2
  68. fractal_server/main.py +72 -11
  69. fractal_server/migrations/versions/7910eed4cf97_user_project_dirs_and_usergroup_viewer_.py +60 -0
  70. fractal_server/migrations/versions/88270f589c9b_add_prevent_new_submissions.py +39 -0
  71. fractal_server/migrations/versions/bc0e8b3327a7_project_sharing.py +72 -0
  72. fractal_server/migrations/versions/f0702066b007_one_submitted_job_per_dataset.py +40 -0
  73. fractal_server/runner/config/_slurm.py +2 -0
  74. fractal_server/runner/executors/slurm_common/_batching.py +4 -10
  75. fractal_server/runner/executors/slurm_common/slurm_config.py +1 -0
  76. fractal_server/runner/executors/slurm_ssh/runner.py +1 -1
  77. fractal_server/runner/executors/slurm_sudo/runner.py +1 -1
  78. fractal_server/runner/v2/_local.py +4 -3
  79. fractal_server/runner/v2/_slurm_ssh.py +4 -3
  80. fractal_server/runner/v2/_slurm_sudo.py +4 -3
  81. fractal_server/runner/v2/runner.py +36 -17
  82. fractal_server/runner/v2/runner_functions.py +11 -14
  83. fractal_server/runner/v2/submit_workflow.py +22 -9
  84. fractal_server/tasks/v2/local/_utils.py +2 -2
  85. fractal_server/tasks/v2/local/collect.py +5 -6
  86. fractal_server/tasks/v2/local/collect_pixi.py +5 -6
  87. fractal_server/tasks/v2/local/deactivate.py +7 -7
  88. fractal_server/tasks/v2/local/deactivate_pixi.py +3 -3
  89. fractal_server/tasks/v2/local/delete.py +5 -5
  90. fractal_server/tasks/v2/local/reactivate.py +5 -5
  91. fractal_server/tasks/v2/local/reactivate_pixi.py +5 -5
  92. fractal_server/tasks/v2/ssh/collect.py +5 -5
  93. fractal_server/tasks/v2/ssh/collect_pixi.py +5 -5
  94. fractal_server/tasks/v2/ssh/deactivate.py +7 -7
  95. fractal_server/tasks/v2/ssh/deactivate_pixi.py +2 -2
  96. fractal_server/tasks/v2/ssh/delete.py +5 -5
  97. fractal_server/tasks/v2/ssh/reactivate.py +5 -5
  98. fractal_server/tasks/v2/ssh/reactivate_pixi.py +5 -5
  99. fractal_server/tasks/v2/utils_background.py +7 -7
  100. fractal_server/tasks/v2/utils_database.py +5 -5
  101. fractal_server/types/__init__.py +22 -0
  102. fractal_server/types/validators/__init__.py +3 -0
  103. fractal_server/types/validators/_common_validators.py +32 -0
  104. {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/METADATA +3 -2
  105. {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/RECORD +108 -98
  106. {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/WHEEL +0 -0
  107. {fractal_server-2.17.2.dist-info → fractal_server-2.18.0.dist-info}/entry_points.txt +0 -0
  108. {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 TaskImportV2
23
- from fractal_server.app.schemas.v2 import TaskImportV2Legacy
24
- from fractal_server.app.schemas.v2 import WorkflowImportV2
25
- from fractal_server.app.schemas.v2 import WorkflowReadV2WithWarnings
26
- from fractal_server.app.schemas.v2 import WorkflowTaskCreateV2
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 _get_project_check_owner
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.info(
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: TaskImportV2,
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.info(f"[_get_task_by_taskimport] START, {task_import=}")
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.info(
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.info(
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.info(
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.info(
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.info(
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.info(
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.info(
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.info(f"[_get_task_by_taskimport] END, {task_import=}, {task_id=}.")
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=WorkflowReadV2WithWarnings,
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: WorkflowImportV2,
215
+ workflow_import: WorkflowImport,
215
216
  user: UserOAuth = Depends(current_user_act_ver_prof),
216
217
  db: AsyncSession = Depends(get_async_db),
217
- ) -> WorkflowReadV2WithWarnings:
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 _get_project_check_owner(
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, TaskImportV2Legacy):
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 = WorkflowTaskCreateV2(
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 WorkflowTaskCreateV2
15
- from fractal_server.app.schemas.v2 import WorkflowTaskReadV2
16
- from fractal_server.app.schemas.v2 import WorkflowTaskUpdateV2
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 _get_workflow_check_owner
19
- from ._aux_functions import _get_workflow_task_check_owner
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=WorkflowTaskReadV2,
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: WorkflowTaskCreateV2,
38
+ wftask: WorkflowTaskCreate,
38
39
  user: UserOAuth = Depends(current_user_act_ver_prof),
39
40
  db: AsyncSession = Depends(get_async_db),
40
- ) -> WorkflowTaskReadV2 | None:
41
+ ) -> WorkflowTaskRead | None:
41
42
  """
42
43
  Add a WorkflowTask to a Workflow
43
44
  """
44
45
 
45
- workflow = await _get_workflow_check_owner(
46
- project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
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=WorkflowTaskReadV2,
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 _get_workflow_task_check_owner(
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=WorkflowTaskReadV2,
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: WorkflowTaskUpdateV2,
131
+ workflow_task_update: WorkflowTaskUpdate,
126
132
  user: UserOAuth = Depends(current_user_act_ver_prof),
127
133
  db: AsyncSession = Depends(get_async_db),
128
- ) -> WorkflowTaskReadV2 | None:
134
+ ) -> WorkflowTaskRead | None:
129
135
  """
130
136
  Edit a WorkflowTask of a Workflow
131
137
  """
132
138
 
133
- db_wf_task, db_workflow = await _get_workflow_task_check_owner(
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 _get_workflow_task_check_owner(
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