fractal-server 2.8.1__py3-none-any.whl → 2.9.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 (81) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/db/__init__.py +2 -35
  3. fractal_server/app/models/v2/__init__.py +3 -3
  4. fractal_server/app/models/v2/task.py +0 -72
  5. fractal_server/app/models/v2/task_group.py +113 -0
  6. fractal_server/app/routes/admin/v1.py +13 -30
  7. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  8. fractal_server/app/routes/admin/v2/job.py +13 -24
  9. fractal_server/app/routes/admin/v2/task.py +13 -0
  10. fractal_server/app/routes/admin/v2/task_group.py +75 -14
  11. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +267 -0
  12. fractal_server/app/routes/api/v1/project.py +7 -19
  13. fractal_server/app/routes/api/v2/__init__.py +11 -2
  14. fractal_server/app/routes/api/v2/{_aux_functions_task_collection.py → _aux_functions_task_lifecycle.py} +83 -0
  15. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +27 -17
  16. fractal_server/app/routes/api/v2/submit.py +19 -24
  17. fractal_server/app/routes/api/v2/task_collection.py +33 -65
  18. fractal_server/app/routes/api/v2/task_collection_custom.py +3 -3
  19. fractal_server/app/routes/api/v2/task_group.py +86 -14
  20. fractal_server/app/routes/api/v2/task_group_lifecycle.py +272 -0
  21. fractal_server/app/routes/api/v2/workflow.py +1 -1
  22. fractal_server/app/routes/api/v2/workflow_import.py +2 -2
  23. fractal_server/app/routes/auth/current_user.py +60 -17
  24. fractal_server/app/routes/auth/group.py +67 -39
  25. fractal_server/app/routes/auth/users.py +97 -99
  26. fractal_server/app/routes/aux/__init__.py +20 -0
  27. fractal_server/app/runner/executors/slurm/_slurm_config.py +0 -17
  28. fractal_server/app/runner/executors/slurm/ssh/executor.py +49 -204
  29. fractal_server/app/runner/executors/slurm/sudo/executor.py +26 -109
  30. fractal_server/app/runner/executors/slurm/utils_executors.py +58 -0
  31. fractal_server/app/runner/v2/_local_experimental/executor.py +2 -1
  32. fractal_server/app/schemas/_validators.py +0 -15
  33. fractal_server/app/schemas/user.py +16 -10
  34. fractal_server/app/schemas/user_group.py +0 -11
  35. fractal_server/app/schemas/v1/applyworkflow.py +0 -8
  36. fractal_server/app/schemas/v1/dataset.py +0 -5
  37. fractal_server/app/schemas/v1/project.py +0 -5
  38. fractal_server/app/schemas/v1/state.py +0 -5
  39. fractal_server/app/schemas/v1/workflow.py +0 -5
  40. fractal_server/app/schemas/v2/__init__.py +4 -2
  41. fractal_server/app/schemas/v2/dataset.py +0 -6
  42. fractal_server/app/schemas/v2/job.py +0 -8
  43. fractal_server/app/schemas/v2/project.py +0 -5
  44. fractal_server/app/schemas/v2/task_collection.py +0 -21
  45. fractal_server/app/schemas/v2/task_group.py +59 -8
  46. fractal_server/app/schemas/v2/workflow.py +0 -5
  47. fractal_server/app/security/__init__.py +17 -0
  48. fractal_server/config.py +61 -59
  49. fractal_server/migrations/versions/d256a7379ab8_taskgroup_activity_and_venv_info_to_.py +117 -0
  50. fractal_server/ssh/_fabric.py +156 -83
  51. fractal_server/tasks/utils.py +2 -12
  52. fractal_server/tasks/v2/local/__init__.py +3 -0
  53. fractal_server/tasks/v2/local/_utils.py +70 -0
  54. fractal_server/tasks/v2/local/collect.py +291 -0
  55. fractal_server/tasks/v2/local/deactivate.py +218 -0
  56. fractal_server/tasks/v2/local/reactivate.py +159 -0
  57. fractal_server/tasks/v2/ssh/__init__.py +3 -0
  58. fractal_server/tasks/v2/ssh/_utils.py +87 -0
  59. fractal_server/tasks/v2/ssh/collect.py +311 -0
  60. fractal_server/tasks/v2/ssh/deactivate.py +253 -0
  61. fractal_server/tasks/v2/ssh/reactivate.py +202 -0
  62. fractal_server/tasks/v2/templates/{_2_preliminary_pip_operations.sh → 1_create_venv.sh} +6 -7
  63. fractal_server/tasks/v2/templates/{_3_pip_install.sh → 2_pip_install.sh} +8 -1
  64. fractal_server/tasks/v2/templates/{_4_pip_freeze.sh → 3_pip_freeze.sh} +0 -7
  65. fractal_server/tasks/v2/templates/{_5_pip_show.sh → 4_pip_show.sh} +5 -6
  66. fractal_server/tasks/v2/templates/5_get_venv_size_and_file_number.sh +10 -0
  67. fractal_server/tasks/v2/templates/6_pip_install_from_freeze.sh +35 -0
  68. fractal_server/tasks/v2/utils_background.py +42 -127
  69. fractal_server/tasks/v2/utils_templates.py +32 -2
  70. fractal_server/utils.py +4 -2
  71. fractal_server/zip_tools.py +21 -4
  72. {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/METADATA +3 -5
  73. {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/RECORD +77 -64
  74. fractal_server/app/models/v2/collection_state.py +0 -22
  75. fractal_server/tasks/v2/collection_local.py +0 -357
  76. fractal_server/tasks/v2/collection_ssh.py +0 -352
  77. fractal_server/tasks/v2/templates/_1_create_venv.sh +0 -42
  78. /fractal_server/tasks/v2/{database_operations.py → utils_database.py} +0 -0
  79. {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/LICENSE +0 -0
  80. {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/WHEEL +0 -0
  81. {fractal_server-2.8.1.dist-info → fractal_server-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,272 @@
1
+ from fastapi import APIRouter
2
+ from fastapi import BackgroundTasks
3
+ from fastapi import Depends
4
+ from fastapi import HTTPException
5
+ from fastapi import Request
6
+ from fastapi import Response
7
+ from fastapi import status
8
+
9
+ from ...aux.validate_user_settings import validate_user_settings
10
+ from ._aux_functions_task_lifecycle import check_no_ongoing_activity
11
+ from ._aux_functions_task_lifecycle import check_no_submitted_job
12
+ from ._aux_functions_tasks import _get_task_group_full_access
13
+ from fractal_server.app.db import AsyncSession
14
+ from fractal_server.app.db import get_async_db
15
+ from fractal_server.app.models import UserOAuth
16
+ from fractal_server.app.models.v2 import TaskGroupActivityV2
17
+ from fractal_server.app.routes.auth import current_active_user
18
+ from fractal_server.app.schemas.v2 import TaskGroupActivityActionV2
19
+ from fractal_server.app.schemas.v2 import TaskGroupActivityStatusV2
20
+ from fractal_server.app.schemas.v2 import TaskGroupActivityV2Read
21
+ from fractal_server.app.schemas.v2 import TaskGroupReadV2
22
+ from fractal_server.app.schemas.v2 import TaskGroupV2OriginEnum
23
+ from fractal_server.config import get_settings
24
+ from fractal_server.logger import set_logger
25
+ from fractal_server.syringe import Inject
26
+ from fractal_server.tasks.v2.local import deactivate_local
27
+ from fractal_server.tasks.v2.local import reactivate_local
28
+ from fractal_server.tasks.v2.ssh import deactivate_ssh
29
+ from fractal_server.tasks.v2.ssh import reactivate_ssh
30
+ from fractal_server.utils import get_timestamp
31
+
32
+ router = APIRouter()
33
+
34
+
35
+ logger = set_logger(__name__)
36
+
37
+
38
+ @router.post(
39
+ "/{task_group_id}/deactivate/",
40
+ response_model=TaskGroupActivityV2Read,
41
+ )
42
+ async def deactivate_task_group(
43
+ task_group_id: int,
44
+ background_tasks: BackgroundTasks,
45
+ response: Response,
46
+ request: Request,
47
+ user: UserOAuth = Depends(current_active_user),
48
+ db: AsyncSession = Depends(get_async_db),
49
+ ) -> TaskGroupReadV2:
50
+ """
51
+ Deactivate task-group venv
52
+ """
53
+ # Check access
54
+ task_group = await _get_task_group_full_access(
55
+ task_group_id=task_group_id,
56
+ user_id=user.id,
57
+ db=db,
58
+ )
59
+
60
+ # Check no other activity is ongoing
61
+ await check_no_ongoing_activity(task_group_id=task_group_id, db=db)
62
+
63
+ # Check no submitted jobs use tasks from this task group
64
+ await check_no_submitted_job(task_group_id=task_group.id, db=db)
65
+
66
+ # Check that task-group is active
67
+ if not task_group.active:
68
+ raise HTTPException(
69
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
70
+ detail=(
71
+ f"Cannot deactivate a task group with {task_group.active=}."
72
+ ),
73
+ )
74
+
75
+ # Shortcut for task-group with origin="other"
76
+ if task_group.origin == TaskGroupV2OriginEnum.OTHER:
77
+ task_group.active = False
78
+ task_group_activity = TaskGroupActivityV2(
79
+ user_id=task_group.user_id,
80
+ taskgroupv2_id=task_group.id,
81
+ status=TaskGroupActivityStatusV2.OK,
82
+ action=TaskGroupActivityActionV2.DEACTIVATE,
83
+ pkg_name=task_group.pkg_name,
84
+ version=(task_group.version or "N/A"),
85
+ log=(
86
+ f"Task group has {task_group.origin=}, set "
87
+ "task_group.active to False and exit."
88
+ ),
89
+ timestamp_started=get_timestamp(),
90
+ timestamp_ended=get_timestamp(),
91
+ )
92
+ db.add(task_group)
93
+ db.add(task_group_activity)
94
+ await db.commit()
95
+ response.status_code = status.HTTP_202_ACCEPTED
96
+ return task_group_activity
97
+
98
+ task_group_activity = TaskGroupActivityV2(
99
+ user_id=task_group.user_id,
100
+ taskgroupv2_id=task_group.id,
101
+ status=TaskGroupActivityStatusV2.PENDING,
102
+ action=TaskGroupActivityActionV2.DEACTIVATE,
103
+ pkg_name=task_group.pkg_name,
104
+ version=task_group.version,
105
+ timestamp_started=get_timestamp(),
106
+ )
107
+ task_group.active = False
108
+ db.add(task_group)
109
+ db.add(task_group_activity)
110
+ await db.commit()
111
+
112
+ # Submit background task
113
+ settings = Inject(get_settings)
114
+ if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
115
+
116
+ # Validate user settings (backend-specific)
117
+ user_settings = await validate_user_settings(
118
+ user=user, backend=settings.FRACTAL_RUNNER_BACKEND, db=db
119
+ )
120
+
121
+ # User appropriate FractalSSH object
122
+ ssh_credentials = dict(
123
+ user=user_settings.ssh_username,
124
+ host=user_settings.ssh_host,
125
+ key_path=user_settings.ssh_private_key_path,
126
+ )
127
+ fractal_ssh_list = request.app.state.fractal_ssh_list
128
+ fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
129
+
130
+ background_tasks.add_task(
131
+ deactivate_ssh,
132
+ task_group_id=task_group.id,
133
+ task_group_activity_id=task_group_activity.id,
134
+ fractal_ssh=fractal_ssh,
135
+ tasks_base_dir=user_settings.ssh_tasks_dir,
136
+ )
137
+
138
+ else:
139
+ background_tasks.add_task(
140
+ deactivate_local,
141
+ task_group_id=task_group.id,
142
+ task_group_activity_id=task_group_activity.id,
143
+ )
144
+
145
+ logger.debug(
146
+ "Task group deactivation endpoint: start deactivate "
147
+ "and return task_group_activity"
148
+ )
149
+ response.status_code = status.HTTP_202_ACCEPTED
150
+ return task_group_activity
151
+
152
+
153
+ @router.post(
154
+ "/{task_group_id}/reactivate/",
155
+ response_model=TaskGroupActivityV2Read,
156
+ )
157
+ async def reactivate_task_group(
158
+ task_group_id: int,
159
+ background_tasks: BackgroundTasks,
160
+ response: Response,
161
+ request: Request,
162
+ user: UserOAuth = Depends(current_active_user),
163
+ db: AsyncSession = Depends(get_async_db),
164
+ ) -> TaskGroupReadV2:
165
+ """
166
+ Deactivate task-group venv
167
+ """
168
+
169
+ # Check access
170
+ task_group = await _get_task_group_full_access(
171
+ task_group_id=task_group_id,
172
+ user_id=user.id,
173
+ db=db,
174
+ )
175
+
176
+ # Check that task-group is not active
177
+ if task_group.active:
178
+ raise HTTPException(
179
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
180
+ detail=(
181
+ f"Cannot reactivate a task group with {task_group.active=}."
182
+ ),
183
+ )
184
+
185
+ # Check no other activity is ongoing
186
+ await check_no_ongoing_activity(task_group_id=task_group_id, db=db)
187
+
188
+ # Check no submitted jobs use tasks from this task group
189
+ await check_no_submitted_job(task_group_id=task_group.id, db=db)
190
+
191
+ # Shortcut for task-group with origin="other"
192
+ if task_group.origin == TaskGroupV2OriginEnum.OTHER:
193
+ task_group.active = True
194
+ task_group_activity = TaskGroupActivityV2(
195
+ user_id=task_group.user_id,
196
+ taskgroupv2_id=task_group.id,
197
+ status=TaskGroupActivityStatusV2.OK,
198
+ action=TaskGroupActivityActionV2.REACTIVATE,
199
+ pkg_name=task_group.pkg_name,
200
+ version=(task_group.version or "N/A"),
201
+ log=(
202
+ f"Task group has {task_group.origin=}, set "
203
+ "task_group.active to True and exit."
204
+ ),
205
+ timestamp_started=get_timestamp(),
206
+ timestamp_ended=get_timestamp(),
207
+ )
208
+ db.add(task_group)
209
+ db.add(task_group_activity)
210
+ await db.commit()
211
+ response.status_code = status.HTTP_202_ACCEPTED
212
+ return task_group_activity
213
+
214
+ if task_group.pip_freeze is None:
215
+ raise HTTPException(
216
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
217
+ detail=(
218
+ "Cannot reactivate a task group with "
219
+ f"{task_group.pip_freeze=}."
220
+ ),
221
+ )
222
+
223
+ task_group_activity = TaskGroupActivityV2(
224
+ user_id=task_group.user_id,
225
+ taskgroupv2_id=task_group.id,
226
+ status=TaskGroupActivityStatusV2.PENDING,
227
+ action=TaskGroupActivityActionV2.REACTIVATE,
228
+ pkg_name=task_group.pkg_name,
229
+ version=task_group.version,
230
+ timestamp_started=get_timestamp(),
231
+ )
232
+ db.add(task_group_activity)
233
+ await db.commit()
234
+
235
+ # Submit background task
236
+ settings = Inject(get_settings)
237
+ if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
238
+
239
+ # Validate user settings (backend-specific)
240
+ user_settings = await validate_user_settings(
241
+ user=user, backend=settings.FRACTAL_RUNNER_BACKEND, db=db
242
+ )
243
+
244
+ # Use appropriate FractalSSH object
245
+ ssh_credentials = dict(
246
+ user=user_settings.ssh_username,
247
+ host=user_settings.ssh_host,
248
+ key_path=user_settings.ssh_private_key_path,
249
+ )
250
+ fractal_ssh_list = request.app.state.fractal_ssh_list
251
+ fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
252
+
253
+ background_tasks.add_task(
254
+ reactivate_ssh,
255
+ task_group_id=task_group.id,
256
+ task_group_activity_id=task_group_activity.id,
257
+ fractal_ssh=fractal_ssh,
258
+ tasks_base_dir=user_settings.ssh_tasks_dir,
259
+ )
260
+
261
+ else:
262
+ background_tasks.add_task(
263
+ reactivate_local,
264
+ task_group_id=task_group.id,
265
+ task_group_activity_id=task_group_activity.id,
266
+ )
267
+ logger.debug(
268
+ "Task group reactivation endpoint: start reactivate "
269
+ "and return task_group_activity"
270
+ )
271
+ response.status_code = status.HTTP_202_ACCEPTED
272
+ return task_group_activity
@@ -23,7 +23,7 @@ from ._aux_functions import _get_submitted_jobs_statement
23
23
  from ._aux_functions import _get_workflow_check_owner
24
24
  from ._aux_functions_tasks import _add_warnings_to_workflow_tasks
25
25
  from fractal_server.app.models import UserOAuth
26
- from fractal_server.app.models.v2.task import TaskGroupV2
26
+ from fractal_server.app.models.v2 import TaskGroupV2
27
27
  from fractal_server.app.routes.auth import current_active_user
28
28
 
29
29
  router = APIRouter()
@@ -21,10 +21,10 @@ from ._aux_functions import _workflow_insert_task
21
21
  from ._aux_functions_tasks import _add_warnings_to_workflow_tasks
22
22
  from fractal_server.app.models import LinkUserGroup
23
23
  from fractal_server.app.models import UserOAuth
24
- from fractal_server.app.models.v2.task import TaskGroupV2
24
+ from fractal_server.app.models.v2 import TaskGroupV2
25
25
  from fractal_server.app.routes.auth import current_active_user
26
26
  from fractal_server.app.routes.auth._aux_auth import _get_default_usergroup_id
27
- from fractal_server.app.schemas.v2.task import TaskImportV2
27
+ from fractal_server.app.schemas.v2 import TaskImportV2
28
28
  from fractal_server.logger import set_logger
29
29
 
30
30
  router = APIRouter()
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Definition of `/auth/current-user/` endpoints
3
3
  """
4
+ import os
5
+
4
6
  from fastapi import APIRouter
5
7
  from fastapi import Depends
6
8
  from fastapi_users import schemas
@@ -22,6 +24,8 @@ from fractal_server.app.schemas import UserSettingsReadStrict
22
24
  from fractal_server.app.schemas import UserSettingsUpdateStrict
23
25
  from fractal_server.app.security import get_user_manager
24
26
  from fractal_server.app.security import UserManager
27
+ from fractal_server.config import get_settings
28
+ from fractal_server.syringe import Inject
25
29
 
26
30
  router_current_user = APIRouter()
27
31
 
@@ -109,26 +113,65 @@ async def patch_current_user_settings(
109
113
 
110
114
 
111
115
  @router_current_user.get(
112
- "/current-user/viewer-paths/", response_model=list[str]
116
+ "/current-user/allowed-viewer-paths/", response_model=list[str]
113
117
  )
114
- async def get_current_user_viewer_paths(
118
+ async def get_current_user_allowed_viewer_paths(
115
119
  current_user: UserOAuth = Depends(current_active_user),
116
120
  db: AsyncSession = Depends(get_async_db),
117
121
  ) -> list[str]:
118
- """Returns the union of `viewer_paths` for all user's groups"""
119
- cmd = (
120
- select(UserGroup.viewer_paths)
121
- .join(LinkUserGroup)
122
- .where(LinkUserGroup.group_id == UserGroup.id)
123
- .where(LinkUserGroup.user_id == current_user.id)
124
- )
125
- res = await db.execute(cmd)
126
- viewer_paths_nested = res.scalars().all()
122
+ """
123
+ Returns the allowed viewer paths for current user, according to the
124
+ selected FRACTAL_VIEWER_AUTHORIZATION_SCHEME
125
+ """
127
126
 
128
- # Flatten a nested object and make its elements unique
129
- all_viewer_paths_set = set(
130
- path for _viewer_paths in viewer_paths_nested for path in _viewer_paths
131
- )
132
- all_viewer_paths = list(all_viewer_paths_set)
127
+ settings = Inject(get_settings)
128
+
129
+ if settings.FRACTAL_VIEWER_AUTHORIZATION_SCHEME == "none":
130
+ return []
133
131
 
134
- return all_viewer_paths
132
+ authorized_paths = []
133
+
134
+ # Respond with 422 error if user has no settings
135
+ verify_user_has_settings(current_user)
136
+
137
+ # Load current user settings
138
+ current_user_settings = await db.get(
139
+ UserSettings, current_user.user_settings_id
140
+ )
141
+ # If project_dir is set, append it to the list of authorized paths
142
+ if current_user_settings.project_dir is not None:
143
+ authorized_paths.append(current_user_settings.project_dir)
144
+
145
+ # If auth scheme is "users-folders" and `slurm_user` is set,
146
+ # build and append the user folder
147
+ if (
148
+ settings.FRACTAL_VIEWER_AUTHORIZATION_SCHEME == "users-folders"
149
+ and current_user_settings.slurm_user is not None
150
+ ):
151
+ base_folder = settings.FRACTAL_VIEWER_BASE_FOLDER
152
+ user_folder = os.path.join(
153
+ base_folder, current_user_settings.slurm_user
154
+ )
155
+ authorized_paths.append(user_folder)
156
+
157
+ if settings.FRACTAL_VIEWER_AUTHORIZATION_SCHEME == "viewer-paths":
158
+ # Returns the union of `viewer_paths` for all user's groups
159
+ cmd = (
160
+ select(UserGroup.viewer_paths)
161
+ .join(LinkUserGroup)
162
+ .where(LinkUserGroup.group_id == UserGroup.id)
163
+ .where(LinkUserGroup.user_id == current_user.id)
164
+ )
165
+ res = await db.execute(cmd)
166
+ viewer_paths_nested = res.scalars().all()
167
+
168
+ # Flatten a nested object and make its elements unique
169
+ all_viewer_paths_set = set(
170
+ path
171
+ for _viewer_paths in viewer_paths_nested
172
+ for path in _viewer_paths
173
+ )
174
+
175
+ authorized_paths.extend(all_viewer_paths_set)
176
+
177
+ return authorized_paths
@@ -6,14 +6,13 @@ from fastapi import Depends
6
6
  from fastapi import HTTPException
7
7
  from fastapi import Response
8
8
  from fastapi import status
9
- from sqlalchemy.exc import IntegrityError
10
9
  from sqlalchemy.ext.asyncio import AsyncSession
11
- from sqlmodel import col
12
- from sqlmodel import func
13
10
  from sqlmodel import select
14
11
 
15
12
  from . import current_active_superuser
13
+ from ._aux_auth import _get_default_usergroup_id
16
14
  from ._aux_auth import _get_single_usergroup_with_user_ids
15
+ from ._aux_auth import _user_or_404
17
16
  from ._aux_auth import _usergroup_or_404
18
17
  from fractal_server.app.db import get_async_db
19
18
  from fractal_server.app.models import LinkUserGroup
@@ -126,42 +125,6 @@ async def update_single_group(
126
125
 
127
126
  group = await _usergroup_or_404(group_id, db)
128
127
 
129
- # Check that all required users exist
130
- # Note: The reason for introducing `col` is as in
131
- # https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors,
132
- stm = select(func.count()).where(
133
- col(UserOAuth.id).in_(group_update.new_user_ids)
134
- )
135
- res = await db.execute(stm)
136
- number_matching_users = res.scalar()
137
- if number_matching_users != len(group_update.new_user_ids):
138
- raise HTTPException(
139
- status_code=status.HTTP_404_NOT_FOUND,
140
- detail=(
141
- f"Not all requested users (IDs {group_update.new_user_ids}) "
142
- "exist."
143
- ),
144
- )
145
-
146
- # Add new users to existing group
147
- for user_id in group_update.new_user_ids:
148
- link = LinkUserGroup(user_id=user_id, group_id=group_id)
149
- db.add(link)
150
- try:
151
- await db.commit()
152
- except IntegrityError as e:
153
- error_msg = (
154
- f"Cannot link users with IDs {group_update.new_user_ids} "
155
- f"to group {group_id}. "
156
- "Likely reason: one of these links already exists.\n"
157
- f"Original error: {str(e)}"
158
- )
159
- logger.info(error_msg)
160
- raise HTTPException(
161
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
162
- detail=error_msg,
163
- )
164
-
165
128
  # Patch `viewer_paths`
166
129
  if group_update.viewer_paths is not None:
167
130
  group.viewer_paths = group_update.viewer_paths
@@ -239,3 +202,68 @@ async def patch_user_settings_bulk(
239
202
  await db.commit()
240
203
 
241
204
  return Response(status_code=status.HTTP_200_OK)
205
+
206
+
207
+ @router_group.post("/group/{group_id}/add-user/{user_id}/", status_code=200)
208
+ async def add_user_to_group(
209
+ group_id: int,
210
+ user_id: int,
211
+ superuser: UserOAuth = Depends(current_active_superuser),
212
+ db: AsyncSession = Depends(get_async_db),
213
+ ) -> UserGroupRead:
214
+ await _usergroup_or_404(group_id, db)
215
+ user = await _user_or_404(user_id, db)
216
+ link = await db.get(LinkUserGroup, (group_id, user_id))
217
+ if link is None:
218
+ db.add(LinkUserGroup(group_id=group_id, user_id=user_id))
219
+ await db.commit()
220
+ else:
221
+ raise HTTPException(
222
+ status_code=422,
223
+ detail=(
224
+ f"User '{user.email}' is already a member of group {group_id}."
225
+ ),
226
+ )
227
+ group = await _get_single_usergroup_with_user_ids(group_id=group_id, db=db)
228
+ return group
229
+
230
+
231
+ @router_group.post("/group/{group_id}/remove-user/{user_id}/", status_code=200)
232
+ async def remove_user_from_group(
233
+ group_id: int,
234
+ user_id: int,
235
+ superuser: UserOAuth = Depends(current_active_superuser),
236
+ db: AsyncSession = Depends(get_async_db),
237
+ ) -> UserGroupRead:
238
+
239
+ # Check that user and group exist
240
+ await _usergroup_or_404(group_id, db)
241
+ user = await _user_or_404(user_id, db)
242
+
243
+ # Check that group is not the default one
244
+ default_user_group_id = await _get_default_usergroup_id(db=db)
245
+ if default_user_group_id == group_id:
246
+ raise HTTPException(
247
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
248
+ detail=(
249
+ f"Cannot remove user from '{FRACTAL_DEFAULT_GROUP_NAME}' "
250
+ "group.",
251
+ ),
252
+ )
253
+
254
+ link = await db.get(LinkUserGroup, (group_id, user_id))
255
+ if link is None:
256
+ # If user and group are not linked, fail
257
+ raise HTTPException(
258
+ status_code=422,
259
+ detail=f"User '{user.email}' is not a member of group {group_id}.",
260
+ )
261
+ else:
262
+ # If user and group are linked, delete the link
263
+ await db.delete(link)
264
+ await db.commit()
265
+
266
+ # Enrich the response object with user_ids
267
+ group = await _get_single_usergroup_with_user_ids(group_id=group_id, db=db)
268
+
269
+ return group