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
@@ -18,14 +18,15 @@ from fractal_server.app.models.v2 import LinkUserProjectV2
18
18
  from fractal_server.app.routes.auth import current_user_act_ver_prof
19
19
  from fractal_server.app.routes.aux._job import _write_shutdown_file
20
20
  from fractal_server.app.routes.aux._runner import _check_shutdown_is_supported
21
- from fractal_server.app.schemas.v2 import JobReadV2
22
- from fractal_server.app.schemas.v2 import JobStatusTypeV2
21
+ from fractal_server.app.schemas.v2 import JobRead
22
+ from fractal_server.app.schemas.v2 import JobStatusType
23
+ from fractal_server.app.schemas.v2.sharing import ProjectPermissions
23
24
  from fractal_server.runner.filenames import WORKFLOW_LOG_FILENAME
24
25
  from fractal_server.zip_tools import _zip_folder_to_byte_stream_iterator
25
26
 
26
- from ._aux_functions import _get_job_check_owner
27
- from ._aux_functions import _get_project_check_owner
28
- from ._aux_functions import _get_workflow_check_owner
27
+ from ._aux_functions import _get_job_check_access
28
+ from ._aux_functions import _get_project_check_access
29
+ from ._aux_functions import _get_workflow_check_access
29
30
 
30
31
 
31
32
  # https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread
@@ -38,12 +39,12 @@ async def zip_folder_threaded(folder: str) -> Iterator[bytes]:
38
39
  router = APIRouter()
39
40
 
40
41
 
41
- @router.get("/job/", response_model=list[JobReadV2])
42
+ @router.get("/job/", response_model=list[JobRead])
42
43
  async def get_user_jobs(
43
44
  user: UserOAuth = Depends(current_user_act_ver_prof),
44
45
  log: bool = True,
45
46
  db: AsyncSession = Depends(get_async_db),
46
- ) -> list[JobReadV2]:
47
+ ) -> list[JobRead]:
47
48
  """
48
49
  Returns all the jobs of the current user
49
50
  """
@@ -53,6 +54,7 @@ async def get_user_jobs(
53
54
  LinkUserProjectV2, LinkUserProjectV2.project_id == JobV2.project_id
54
55
  )
55
56
  .where(LinkUserProjectV2.user_id == user.id)
57
+ .where(LinkUserProjectV2.is_owner.is_(True))
56
58
  )
57
59
  res = await db.execute(stm)
58
60
  job_list = res.scalars().all()
@@ -66,19 +68,23 @@ async def get_user_jobs(
66
68
 
67
69
  @router.get(
68
70
  "/project/{project_id}/workflow/{workflow_id}/job/",
69
- response_model=list[JobReadV2],
71
+ response_model=list[JobRead],
70
72
  )
71
73
  async def get_workflow_jobs(
72
74
  project_id: int,
73
75
  workflow_id: int,
74
76
  user: UserOAuth = Depends(current_user_act_ver_prof),
75
77
  db: AsyncSession = Depends(get_async_db),
76
- ) -> list[JobReadV2] | None:
78
+ ) -> list[JobRead] | None:
77
79
  """
78
80
  Returns all the jobs related to a specific workflow
79
81
  """
80
- await _get_workflow_check_owner(
81
- project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
82
+ await _get_workflow_check_access(
83
+ project_id=project_id,
84
+ workflow_id=workflow_id,
85
+ user_id=user.id,
86
+ required_permissions=ProjectPermissions.READ,
87
+ db=db,
82
88
  )
83
89
  stm = select(JobV2).where(JobV2.workflow_id == workflow_id)
84
90
  res = await db.execute(stm)
@@ -93,9 +99,13 @@ async def get_latest_job(
93
99
  dataset_id: int,
94
100
  user: UserOAuth = Depends(current_user_act_ver_prof),
95
101
  db: AsyncSession = Depends(get_async_db),
96
- ) -> JobReadV2:
97
- await _get_workflow_check_owner(
98
- project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
102
+ ) -> JobRead:
103
+ await _get_workflow_check_access(
104
+ project_id=project_id,
105
+ workflow_id=workflow_id,
106
+ user_id=user.id,
107
+ required_permissions=ProjectPermissions.READ,
108
+ db=db,
99
109
  )
100
110
  stm = (
101
111
  select(JobV2)
@@ -117,7 +127,7 @@ async def get_latest_job(
117
127
 
118
128
  @router.get(
119
129
  "/project/{project_id}/job/{job_id}/",
120
- response_model=JobReadV2,
130
+ response_model=JobRead,
121
131
  )
122
132
  async def read_job(
123
133
  project_id: int,
@@ -125,21 +135,22 @@ async def read_job(
125
135
  show_tmp_logs: bool = False,
126
136
  user: UserOAuth = Depends(current_user_act_ver_prof),
127
137
  db: AsyncSession = Depends(get_async_db),
128
- ) -> JobReadV2 | None:
138
+ ) -> JobRead | None:
129
139
  """
130
140
  Return info on an existing job
131
141
  """
132
142
 
133
- output = await _get_job_check_owner(
143
+ output = await _get_job_check_access(
134
144
  project_id=project_id,
135
145
  job_id=job_id,
136
146
  user_id=user.id,
147
+ required_permissions=ProjectPermissions.READ,
137
148
  db=db,
138
149
  )
139
150
  job = output["job"]
140
151
  await db.close()
141
152
 
142
- if show_tmp_logs and (job.status == JobStatusTypeV2.SUBMITTED):
153
+ if show_tmp_logs and (job.status == JobStatusType.SUBMITTED):
143
154
  try:
144
155
  with open(f"{job.working_dir}/{WORKFLOW_LOG_FILENAME}") as f:
145
156
  job.log = f.read()
@@ -162,10 +173,11 @@ async def download_job_logs(
162
173
  """
163
174
  Download zipped job folder
164
175
  """
165
- output = await _get_job_check_owner(
176
+ output = await _get_job_check_access(
166
177
  project_id=project_id,
167
178
  job_id=job_id,
168
179
  user_id=user.id,
180
+ required_permissions=ProjectPermissions.READ,
169
181
  db=db,
170
182
  )
171
183
  job = output["job"]
@@ -182,19 +194,22 @@ async def download_job_logs(
182
194
 
183
195
  @router.get(
184
196
  "/project/{project_id}/job/",
185
- response_model=list[JobReadV2],
197
+ response_model=list[JobRead],
186
198
  )
187
199
  async def get_job_list(
188
200
  project_id: int,
189
201
  user: UserOAuth = Depends(current_user_act_ver_prof),
190
202
  log: bool = True,
191
203
  db: AsyncSession = Depends(get_async_db),
192
- ) -> list[JobReadV2] | None:
204
+ ) -> list[JobRead] | None:
193
205
  """
194
206
  Get job list for given project
195
207
  """
196
- project = await _get_project_check_owner(
197
- project_id=project_id, user_id=user.id, db=db
208
+ project = await _get_project_check_access(
209
+ project_id=project_id,
210
+ user_id=user.id,
211
+ required_permissions=ProjectPermissions.READ,
212
+ db=db,
198
213
  )
199
214
 
200
215
  stm = select(JobV2).where(JobV2.project_id == project.id)
@@ -225,10 +240,11 @@ async def stop_job(
225
240
  _check_shutdown_is_supported()
226
241
 
227
242
  # Get job from DB
228
- output = await _get_job_check_owner(
243
+ output = await _get_job_check_access(
229
244
  project_id=project_id,
230
245
  job_id=job_id,
231
246
  user_id=user.id,
247
+ required_permissions=ProjectPermissions.EXECUTE,
232
248
  db=db,
233
249
  )
234
250
  job = output["job"]
@@ -11,14 +11,15 @@ from fractal_server.app.models import UserOAuth
11
11
  from fractal_server.app.routes.auth import current_user_act_ver_prof
12
12
  from fractal_server.app.schemas.v2 import HistoryUnitStatus
13
13
  from fractal_server.app.schemas.v2 import TaskType
14
+ from fractal_server.app.schemas.v2.sharing import ProjectPermissions
14
15
  from fractal_server.images.status_tools import IMAGE_STATUS_KEY
15
16
  from fractal_server.images.status_tools import enrich_images_unsorted_async
16
17
  from fractal_server.images.tools import aggregate_types
17
18
  from fractal_server.images.tools import filter_image_list
18
19
  from fractal_server.types import AttributeFilters
19
20
 
20
- from ._aux_functions import _get_dataset_check_owner
21
- from ._aux_functions import _get_workflow_task_check_owner
21
+ from ._aux_functions import _get_dataset_check_access
22
+ from ._aux_functions import _get_workflow_task_check_access
22
23
  from .images import ImageQuery
23
24
 
24
25
  router = APIRouter()
@@ -37,8 +38,12 @@ async def verify_unique_types(
37
38
  db: AsyncSession = Depends(get_async_db),
38
39
  ) -> list[str]:
39
40
  # Get dataset
40
- output = await _get_dataset_check_owner(
41
- project_id=project_id, dataset_id=dataset_id, user_id=user.id, db=db
41
+ output = await _get_dataset_check_access(
42
+ project_id=project_id,
43
+ dataset_id=dataset_id,
44
+ user_id=user.id,
45
+ required_permissions=ProjectPermissions.READ,
46
+ db=db,
42
47
  )
43
48
  dataset = output["dataset"]
44
49
 
@@ -97,11 +102,12 @@ async def check_non_processed_images(
97
102
  user: UserOAuth = Depends(current_user_act_ver_prof),
98
103
  db: AsyncSession = Depends(get_async_db),
99
104
  ) -> JSONResponse:
100
- db_workflow_task, db_workflow = await _get_workflow_task_check_owner(
105
+ db_workflow_task, db_workflow = await _get_workflow_task_check_access(
101
106
  project_id=project_id,
102
107
  workflow_task_id=workflowtask_id,
103
108
  workflow_id=workflow_id,
104
109
  user_id=user.id,
110
+ required_permissions=ProjectPermissions.READ,
105
111
  db=db,
106
112
  )
107
113
 
@@ -121,10 +127,11 @@ async def check_non_processed_images(
121
127
  # Skip check if previous task is converter
122
128
  return JSONResponse(status_code=200, content=[])
123
129
 
124
- res = await _get_dataset_check_owner(
130
+ res = await _get_dataset_check_access(
125
131
  project_id=project_id,
126
132
  dataset_id=dataset_id,
127
133
  user_id=user.id,
134
+ required_permissions=ProjectPermissions.READ,
128
135
  db=db,
129
136
  )
130
137
  dataset = res["dataset"]
@@ -15,21 +15,23 @@ from fractal_server.app.routes.auth import current_user_act_ver_prof
15
15
  from fractal_server.app.routes.aux.validate_user_profile import (
16
16
  validate_user_profile,
17
17
  )
18
- from fractal_server.app.schemas.v2 import ProjectCreateV2
19
- from fractal_server.app.schemas.v2 import ProjectReadV2
20
- from fractal_server.app.schemas.v2 import ProjectUpdateV2
21
- from fractal_server.logger import reset_logger_handlers
18
+ from fractal_server.app.schemas.v2 import ProjectCreate
19
+ from fractal_server.app.schemas.v2 import ProjectPermissions
20
+ from fractal_server.app.schemas.v2 import ProjectRead
21
+ from fractal_server.app.schemas.v2 import ProjectUpdate
22
22
  from fractal_server.logger import set_logger
23
23
 
24
24
  from ._aux_functions import _check_project_exists
25
- from ._aux_functions import _get_project_check_owner
25
+ from ._aux_functions import _get_project_check_access
26
26
  from ._aux_functions import _get_submitted_jobs_statement
27
27
 
28
+ logger = set_logger(__name__)
28
29
  router = APIRouter()
29
30
 
30
31
 
31
- @router.get("/project/", response_model=list[ProjectReadV2])
32
+ @router.get("/project/", response_model=list[ProjectRead])
32
33
  async def get_list_project(
34
+ is_owner: bool = True,
33
35
  user: UserOAuth = Depends(current_user_act_ver_prof),
34
36
  db: AsyncSession = Depends(get_async_db),
35
37
  ) -> list[ProjectV2]:
@@ -40,6 +42,8 @@ async def get_list_project(
40
42
  select(ProjectV2)
41
43
  .join(LinkUserProjectV2, LinkUserProjectV2.project_id == ProjectV2.id)
42
44
  .where(LinkUserProjectV2.user_id == user.id)
45
+ .where(LinkUserProjectV2.is_owner == is_owner)
46
+ .where(LinkUserProjectV2.is_verified.is_(True))
43
47
  )
44
48
  res = await db.execute(stm)
45
49
  project_list = res.scalars().all()
@@ -47,12 +51,12 @@ async def get_list_project(
47
51
  return project_list
48
52
 
49
53
 
50
- @router.post("/project/", response_model=ProjectReadV2, status_code=201)
54
+ @router.post("/project/", response_model=ProjectRead, status_code=201)
51
55
  async def create_project(
52
- project: ProjectCreateV2,
56
+ project: ProjectCreate,
53
57
  user: UserOAuth = Depends(current_user_act_ver_prof),
54
58
  db: AsyncSession = Depends(get_async_db),
55
- ) -> ProjectReadV2 | None:
59
+ ) -> ProjectRead | None:
56
60
  """
57
61
  Create new project
58
62
  """
@@ -73,7 +77,13 @@ async def create_project(
73
77
  db.add(db_project)
74
78
  await db.flush()
75
79
 
76
- link = LinkUserProjectV2(project_id=db_project.id, user_id=user.id)
80
+ link = LinkUserProjectV2(
81
+ project_id=db_project.id,
82
+ user_id=user.id,
83
+ is_owner=True,
84
+ is_verified=True,
85
+ permissions=ProjectPermissions.EXECUTE,
86
+ )
77
87
  db.add(link)
78
88
 
79
89
  await db.commit()
@@ -82,31 +92,37 @@ async def create_project(
82
92
  return db_project
83
93
 
84
94
 
85
- @router.get("/project/{project_id}/", response_model=ProjectReadV2)
95
+ @router.get("/project/{project_id}/", response_model=ProjectRead)
86
96
  async def read_project(
87
97
  project_id: int,
88
98
  user: UserOAuth = Depends(current_user_act_ver_prof),
89
99
  db: AsyncSession = Depends(get_async_db),
90
- ) -> ProjectReadV2 | None:
100
+ ) -> ProjectRead | None:
91
101
  """
92
102
  Return info on an existing project
93
103
  """
94
- project = await _get_project_check_owner(
95
- project_id=project_id, user_id=user.id, db=db
104
+ project = await _get_project_check_access(
105
+ project_id=project_id,
106
+ user_id=user.id,
107
+ required_permissions=ProjectPermissions.READ,
108
+ db=db,
96
109
  )
97
110
  await db.close()
98
111
  return project
99
112
 
100
113
 
101
- @router.patch("/project/{project_id}/", response_model=ProjectReadV2)
114
+ @router.patch("/project/{project_id}/", response_model=ProjectRead)
102
115
  async def update_project(
103
116
  project_id: int,
104
- project_update: ProjectUpdateV2,
117
+ project_update: ProjectUpdate,
105
118
  user: UserOAuth = Depends(current_user_act_ver_prof),
106
119
  db: AsyncSession = Depends(get_async_db),
107
120
  ):
108
- project = await _get_project_check_owner(
109
- project_id=project_id, user_id=user.id, db=db
121
+ project = await _get_project_check_access(
122
+ project_id=project_id,
123
+ user_id=user.id,
124
+ required_permissions=ProjectPermissions.WRITE,
125
+ db=db,
110
126
  )
111
127
 
112
128
  # Check that there is no project with the same user and name
@@ -134,10 +150,18 @@ async def delete_project(
134
150
  Delete project
135
151
  """
136
152
 
137
- project = await _get_project_check_owner(
138
- project_id=project_id, user_id=user.id, db=db
153
+ project = await _get_project_check_access(
154
+ project_id=project_id,
155
+ user_id=user.id,
156
+ required_permissions=ProjectPermissions.EXECUTE,
157
+ db=db,
139
158
  )
140
- logger = set_logger(__name__)
159
+ link_user_project = await db.get(LinkUserProjectV2, (project_id, user.id))
160
+ if not link_user_project.is_owner:
161
+ raise HTTPException(
162
+ status_code=status.HTTP_403_FORBIDDEN,
163
+ detail="Only the owner can delete a Project.",
164
+ )
141
165
 
142
166
  # Fail if there exist jobs that are submitted and in relation with the
143
167
  # current project.
@@ -154,13 +178,12 @@ async def delete_project(
154
178
  ),
155
179
  )
156
180
 
157
- logger.info(f"Adding Project[{project.id}] to deletion.")
181
+ logger.debug(f"Add project {project.id} to deletion.")
158
182
  await db.delete(project)
159
183
 
160
- logger.info("Committing changes to db...")
184
+ logger.debug("Commit changes to db")
161
185
  await db.commit()
162
186
 
163
- logger.info("Everything has been deleted correctly.")
164
- reset_logger_handlers(logger)
187
+ logger.debug("Everything has been deleted correctly.")
165
188
 
166
189
  return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,311 @@
1
+ from fastapi import APIRouter
2
+ from fastapi import Depends
3
+ from fastapi import HTTPException
4
+ from fastapi import Response
5
+ from fastapi import status
6
+ from pydantic import EmailStr
7
+ from sqlmodel import select
8
+
9
+ from fractal_server.app.db import AsyncSession
10
+ from fractal_server.app.db import get_async_db
11
+ from fractal_server.app.models import UserOAuth
12
+ from fractal_server.app.models.v2 import LinkUserProjectV2
13
+ from fractal_server.app.models.v2 import ProjectV2
14
+ from fractal_server.app.routes.auth import current_user_act_ver_prof
15
+ from fractal_server.app.schemas.v2 import ProjectAccessRead
16
+ from fractal_server.app.schemas.v2 import ProjectGuestCreate
17
+ from fractal_server.app.schemas.v2 import ProjectGuestRead
18
+ from fractal_server.app.schemas.v2 import ProjectGuestUpdate
19
+ from fractal_server.app.schemas.v2 import ProjectInvitationRead
20
+
21
+ from ._aux_functions_sharing import get_link_or_404
22
+ from ._aux_functions_sharing import get_pending_invitation_or_404
23
+ from ._aux_functions_sharing import get_user_id_from_email_or_404
24
+ from ._aux_functions_sharing import raise_403_if_not_owner
25
+ from ._aux_functions_sharing import raise_422_if_link_exists
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ @router.get(
31
+ "/project/{project_id}/guest/",
32
+ response_model=list[ProjectGuestRead],
33
+ )
34
+ async def get_project_guests(
35
+ project_id: int,
36
+ owner: UserOAuth = Depends(current_user_act_ver_prof),
37
+ db: AsyncSession = Depends(get_async_db),
38
+ ) -> list[ProjectGuestRead]:
39
+ """
40
+ Get the list of all the guests of your project (verified or not).
41
+ """
42
+ await raise_403_if_not_owner(user_id=owner.id, project_id=project_id, db=db)
43
+ # Get (email, is_verified, permissions) for all guests
44
+ res = await db.execute(
45
+ select(
46
+ UserOAuth.email,
47
+ LinkUserProjectV2.is_verified,
48
+ LinkUserProjectV2.permissions,
49
+ )
50
+ .join(LinkUserProjectV2, LinkUserProjectV2.user_id == UserOAuth.id)
51
+ .where(LinkUserProjectV2.project_id == project_id)
52
+ .where(LinkUserProjectV2.is_owner.is_(False))
53
+ .order_by(UserOAuth.email)
54
+ )
55
+ guest_tuples = res.all()
56
+ return [
57
+ dict(
58
+ email=guest_email,
59
+ is_verified=is_verified,
60
+ permissions=permissions,
61
+ )
62
+ for guest_email, is_verified, permissions in guest_tuples
63
+ ]
64
+
65
+
66
+ @router.post("/project/{project_id}/guest/", status_code=201)
67
+ async def invite_guest(
68
+ project_id: int,
69
+ email: EmailStr,
70
+ project_invitation: ProjectGuestCreate,
71
+ owner: UserOAuth = Depends(current_user_act_ver_prof),
72
+ db: AsyncSession = Depends(get_async_db),
73
+ ) -> Response:
74
+ """
75
+ Add a guest to your project.
76
+ """
77
+ await raise_403_if_not_owner(user_id=owner.id, project_id=project_id, db=db)
78
+
79
+ guest_id = await get_user_id_from_email_or_404(user_email=email, db=db)
80
+
81
+ await raise_422_if_link_exists(
82
+ user_id=guest_id,
83
+ project_id=project_id,
84
+ db=db,
85
+ )
86
+
87
+ db.add(
88
+ LinkUserProjectV2(
89
+ project_id=project_id,
90
+ user_id=guest_id,
91
+ is_owner=False,
92
+ is_verified=False,
93
+ permissions=project_invitation.permissions,
94
+ )
95
+ )
96
+ await db.commit()
97
+
98
+ return Response(status_code=status.HTTP_201_CREATED)
99
+
100
+
101
+ @router.patch("/project/{project_id}/guest/", status_code=200)
102
+ async def patch_guest(
103
+ project_id: int,
104
+ email: EmailStr,
105
+ update: ProjectGuestUpdate,
106
+ owner: UserOAuth = Depends(current_user_act_ver_prof),
107
+ db: AsyncSession = Depends(get_async_db),
108
+ ) -> Response:
109
+ """
110
+ Change guest's permissions on your project.
111
+ """
112
+ await raise_403_if_not_owner(user_id=owner.id, project_id=project_id, db=db)
113
+
114
+ guest_id = await get_user_id_from_email_or_404(user_email=email, db=db)
115
+
116
+ if guest_id == owner.id:
117
+ raise HTTPException(
118
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
119
+ detail="Cannot perform this operation on project owner.",
120
+ )
121
+
122
+ link = await get_link_or_404(
123
+ user_id=guest_id,
124
+ project_id=project_id,
125
+ db=db,
126
+ )
127
+
128
+ # Update link and commit
129
+ for key, value in update.model_dump(exclude_unset=True).items():
130
+ setattr(link, key, value)
131
+ await db.commit()
132
+
133
+ return Response(status_code=status.HTTP_200_OK)
134
+
135
+
136
+ @router.delete("/project/{project_id}/guest/", status_code=204)
137
+ async def revoke_guest_access(
138
+ project_id: int,
139
+ email: EmailStr,
140
+ owner: UserOAuth = Depends(current_user_act_ver_prof),
141
+ db: AsyncSession = Depends(get_async_db),
142
+ ) -> Response:
143
+ """
144
+ Remove a guest from your project.
145
+ """
146
+ await raise_403_if_not_owner(user_id=owner.id, project_id=project_id, db=db)
147
+
148
+ guest_id = await get_user_id_from_email_or_404(user_email=email, db=db)
149
+
150
+ if guest_id == owner.id:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
153
+ detail="Cannot perform this operation on project owner.",
154
+ )
155
+
156
+ link = await get_link_or_404(
157
+ user_id=guest_id,
158
+ project_id=project_id,
159
+ db=db,
160
+ )
161
+
162
+ # Delete link and commit
163
+ await db.delete(link)
164
+ await db.commit()
165
+
166
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
167
+
168
+
169
+ @router.get(
170
+ "/project/invitation/",
171
+ response_model=list[ProjectInvitationRead],
172
+ )
173
+ async def get_pending_invitations(
174
+ user: UserOAuth = Depends(current_user_act_ver_prof),
175
+ db: AsyncSession = Depends(get_async_db),
176
+ ) -> list[ProjectInvitationRead]:
177
+ """
178
+ See your current invitations.
179
+ """
180
+
181
+ res = await db.execute(
182
+ select(
183
+ ProjectV2.id,
184
+ ProjectV2.name,
185
+ LinkUserProjectV2.permissions,
186
+ (
187
+ select(UserOAuth.email)
188
+ .join(
189
+ LinkUserProjectV2,
190
+ UserOAuth.id == LinkUserProjectV2.user_id,
191
+ )
192
+ .where(LinkUserProjectV2.is_owner.is_(True))
193
+ .where(LinkUserProjectV2.project_id == ProjectV2.id)
194
+ .scalar_subquery()
195
+ .correlate(ProjectV2)
196
+ ),
197
+ )
198
+ .join(LinkUserProjectV2, LinkUserProjectV2.project_id == ProjectV2.id)
199
+ .where(LinkUserProjectV2.user_id == user.id)
200
+ .where(LinkUserProjectV2.is_verified.is_(False))
201
+ .order_by(ProjectV2.name)
202
+ )
203
+
204
+ guest_project_info = res.all()
205
+
206
+ return [
207
+ dict(
208
+ project_id=project_id,
209
+ project_name=project_name,
210
+ guest_permissions=guest_permissions,
211
+ owner_email=owner_email,
212
+ )
213
+ for (
214
+ project_id,
215
+ project_name,
216
+ guest_permissions,
217
+ owner_email,
218
+ ) in guest_project_info
219
+ ]
220
+
221
+
222
+ @router.get(
223
+ "/project/{project_id}/access/",
224
+ response_model=ProjectAccessRead,
225
+ )
226
+ async def get_access_info(
227
+ project_id: int,
228
+ user: UserOAuth = Depends(current_user_act_ver_prof),
229
+ db: AsyncSession = Depends(get_async_db),
230
+ ) -> ProjectAccessRead:
231
+ """
232
+ Returns information on your relationship with Project[`project_id`].
233
+ """
234
+
235
+ res = await db.execute(
236
+ select(
237
+ LinkUserProjectV2.is_owner,
238
+ LinkUserProjectV2.permissions,
239
+ (
240
+ select(UserOAuth.email)
241
+ .join(
242
+ LinkUserProjectV2,
243
+ UserOAuth.id == LinkUserProjectV2.user_id,
244
+ )
245
+ .where(LinkUserProjectV2.is_owner.is_(True))
246
+ .where(LinkUserProjectV2.project_id == project_id)
247
+ .scalar_subquery()
248
+ ),
249
+ )
250
+ .where(LinkUserProjectV2.project_id == project_id)
251
+ .where(LinkUserProjectV2.user_id == user.id)
252
+ .where(LinkUserProjectV2.is_verified.is_(True))
253
+ )
254
+
255
+ guest_project_info = res.one_or_none()
256
+
257
+ if guest_project_info is None:
258
+ raise HTTPException(
259
+ status_code=status.HTTP_404_NOT_FOUND,
260
+ detail=f"User has no access to project {project_id}.",
261
+ )
262
+
263
+ is_owner, permissions, owner_email = guest_project_info
264
+
265
+ return dict(
266
+ is_owner=is_owner,
267
+ permissions=permissions,
268
+ owner_email=owner_email,
269
+ )
270
+
271
+
272
+ @router.post("/project/{project_id}/access/accept/", status_code=200)
273
+ async def accept_project_invitation(
274
+ project_id: int,
275
+ user: UserOAuth = Depends(current_user_act_ver_prof),
276
+ db: AsyncSession = Depends(get_async_db),
277
+ ) -> Response:
278
+ """
279
+ Accept invitation to project `project_id`.
280
+ """
281
+ link = await get_pending_invitation_or_404(
282
+ user_id=user.id, project_id=project_id, db=db
283
+ )
284
+ link.is_verified = True
285
+ await db.commit()
286
+
287
+ return Response(status_code=status.HTTP_200_OK)
288
+
289
+
290
+ @router.delete("/project/{project_id}/access/", status_code=204)
291
+ async def leave_project(
292
+ project_id: int,
293
+ user: UserOAuth = Depends(current_user_act_ver_prof),
294
+ db: AsyncSession = Depends(get_async_db),
295
+ ) -> Response:
296
+ """
297
+ Decline invitation to project `project_id` or stop being a guest of that
298
+ project.
299
+ """
300
+ link = await get_link_or_404(user_id=user.id, project_id=project_id, db=db)
301
+
302
+ if link.is_owner:
303
+ raise HTTPException(
304
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
305
+ detail=f"You are the owner of project {project_id}.",
306
+ )
307
+
308
+ await db.delete(link)
309
+ await db.commit()
310
+
311
+ return Response(status_code=status.HTTP_204_NO_CONTENT)