fractal-server 2.17.1a1__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 (225) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +21 -19
  3. fractal_server/app/db/__init__.py +3 -3
  4. fractal_server/app/models/__init__.py +1 -0
  5. fractal_server/app/models/linkuserproject.py +43 -1
  6. fractal_server/app/models/security.py +28 -8
  7. fractal_server/app/models/v2/__init__.py +3 -1
  8. fractal_server/app/models/v2/accounting.py +9 -1
  9. fractal_server/app/models/v2/dataset.py +5 -1
  10. fractal_server/app/models/v2/history.py +15 -1
  11. fractal_server/app/models/v2/job.py +17 -2
  12. fractal_server/app/models/v2/profile.py +29 -0
  13. fractal_server/app/models/v2/project.py +4 -10
  14. fractal_server/app/models/v2/resource.py +17 -0
  15. fractal_server/app/models/v2/task_group.py +4 -3
  16. fractal_server/app/models/v2/workflow.py +2 -1
  17. fractal_server/app/routes/admin/v2/__init__.py +12 -13
  18. fractal_server/app/routes/admin/v2/accounting.py +3 -3
  19. fractal_server/app/routes/admin/v2/job.py +35 -24
  20. fractal_server/app/routes/admin/v2/profile.py +3 -2
  21. fractal_server/app/routes/admin/v2/resource.py +5 -5
  22. fractal_server/app/routes/admin/v2/sharing.py +103 -0
  23. fractal_server/app/routes/admin/v2/task.py +37 -26
  24. fractal_server/app/routes/admin/v2/task_group.py +94 -17
  25. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +21 -22
  26. fractal_server/app/routes/api/__init__.py +1 -9
  27. fractal_server/app/routes/api/v2/__init__.py +49 -50
  28. fractal_server/app/routes/api/v2/_aux_functions.py +132 -124
  29. fractal_server/app/routes/api/v2/_aux_functions_history.py +51 -23
  30. fractal_server/app/routes/api/v2/_aux_functions_sharing.py +97 -0
  31. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +6 -8
  32. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +7 -9
  33. fractal_server/app/routes/api/v2/_aux_task_group_disambiguation.py +1 -2
  34. fractal_server/app/routes/api/v2/dataset.py +95 -102
  35. fractal_server/app/routes/api/v2/history.py +59 -33
  36. fractal_server/app/routes/api/v2/images.py +24 -9
  37. fractal_server/app/routes/api/v2/job.py +52 -33
  38. fractal_server/app/routes/api/v2/pre_submission_checks.py +16 -8
  39. fractal_server/app/routes/api/v2/project.py +65 -37
  40. fractal_server/app/routes/api/v2/sharing.py +311 -0
  41. fractal_server/app/routes/api/v2/status_legacy.py +31 -41
  42. fractal_server/app/routes/api/v2/submit.py +82 -78
  43. fractal_server/app/routes/api/v2/task.py +19 -20
  44. fractal_server/app/routes/api/v2/task_collection.py +41 -43
  45. fractal_server/app/routes/api/v2/task_collection_custom.py +19 -20
  46. fractal_server/app/routes/api/v2/task_collection_pixi.py +10 -11
  47. fractal_server/app/routes/api/v2/task_group.py +25 -24
  48. fractal_server/app/routes/api/v2/task_group_lifecycle.py +32 -32
  49. fractal_server/app/routes/api/v2/task_version_update.py +23 -19
  50. fractal_server/app/routes/api/v2/workflow.py +50 -55
  51. fractal_server/app/routes/api/v2/workflow_import.py +37 -37
  52. fractal_server/app/routes/api/v2/workflowtask.py +32 -26
  53. fractal_server/app/routes/auth/__init__.py +1 -3
  54. fractal_server/app/routes/auth/_aux_auth.py +101 -2
  55. fractal_server/app/routes/auth/current_user.py +2 -66
  56. fractal_server/app/routes/auth/group.py +8 -35
  57. fractal_server/app/routes/auth/login.py +1 -0
  58. fractal_server/app/routes/auth/oauth.py +4 -3
  59. fractal_server/app/routes/auth/register.py +4 -2
  60. fractal_server/app/routes/auth/router.py +2 -0
  61. fractal_server/app/routes/auth/users.py +19 -10
  62. fractal_server/app/routes/auth/viewer_paths.py +43 -0
  63. fractal_server/app/routes/aux/_job.py +1 -1
  64. fractal_server/app/routes/aux/_runner.py +2 -2
  65. fractal_server/app/routes/pagination.py +1 -1
  66. fractal_server/app/schemas/user.py +29 -12
  67. fractal_server/app/schemas/user_group.py +0 -15
  68. fractal_server/app/schemas/v2/__init__.py +55 -48
  69. fractal_server/app/schemas/v2/accounting.py +11 -0
  70. fractal_server/app/schemas/v2/dataset.py +57 -11
  71. fractal_server/app/schemas/v2/dumps.py +10 -9
  72. fractal_server/app/schemas/v2/job.py +11 -11
  73. fractal_server/app/schemas/v2/manifest.py +4 -3
  74. fractal_server/app/schemas/v2/profile.py +53 -2
  75. fractal_server/app/schemas/v2/project.py +3 -3
  76. fractal_server/app/schemas/v2/resource.py +121 -16
  77. fractal_server/app/schemas/v2/sharing.py +99 -0
  78. fractal_server/app/schemas/v2/status_legacy.py +3 -3
  79. fractal_server/app/schemas/v2/task.py +6 -7
  80. fractal_server/app/schemas/v2/task_collection.py +5 -5
  81. fractal_server/app/schemas/v2/task_group.py +16 -16
  82. fractal_server/app/schemas/v2/workflow.py +16 -16
  83. fractal_server/app/schemas/v2/workflowtask.py +16 -15
  84. fractal_server/app/security/__init__.py +5 -8
  85. fractal_server/app/security/signup_email.py +4 -5
  86. fractal_server/app/shutdown.py +6 -6
  87. fractal_server/config/__init__.py +0 -6
  88. fractal_server/config/_data.py +0 -68
  89. fractal_server/config/_database.py +19 -20
  90. fractal_server/config/_email.py +30 -38
  91. fractal_server/config/_main.py +38 -52
  92. fractal_server/config/_oauth.py +17 -21
  93. fractal_server/data_migrations/2_18_0.py +30 -0
  94. fractal_server/exceptions.py +4 -0
  95. fractal_server/images/models.py +4 -5
  96. fractal_server/images/status_tools.py +4 -2
  97. fractal_server/logger.py +1 -1
  98. fractal_server/main.py +75 -13
  99. fractal_server/migrations/versions/034a469ec2eb_task_groups.py +4 -8
  100. fractal_server/migrations/versions/091b01f51f88_add_usergroup_and_linkusergroup_table.py +1 -1
  101. fractal_server/migrations/versions/0f5f85bb2ae7_add_pre_pinned_packages.py +1 -0
  102. fractal_server/migrations/versions/19eca0dd47a9_user_settings_project_dir.py +1 -1
  103. fractal_server/migrations/versions/1a83a5260664_rename.py +1 -1
  104. fractal_server/migrations/versions/1eac13a26c83_drop_v1_tables.py +1 -0
  105. fractal_server/migrations/versions/316140ff7ee1_remove_usersettings_cache_dir.py +1 -1
  106. fractal_server/migrations/versions/40d6d6511b20_add_index_to_history_models.py +47 -0
  107. fractal_server/migrations/versions/45fbb391d7af_make_resource_id_fk_non_nullable.py +1 -1
  108. fractal_server/migrations/versions/47351f8c7ebc_drop_dataset_filters.py +1 -0
  109. fractal_server/migrations/versions/49d0856e9569_drop_table.py +2 -3
  110. fractal_server/migrations/versions/4c308bcaea2b_add_task_args_schema_and_task_args_.py +1 -1
  111. fractal_server/migrations/versions/4cedeb448a53_workflowtask_foreign_keys_not_nullables.py +1 -1
  112. fractal_server/migrations/versions/501961cfcd85_remove_link_between_v1_and_v2_tasks_.py +2 -1
  113. fractal_server/migrations/versions/50a13d6138fd_initial_schema.py +7 -19
  114. fractal_server/migrations/versions/5bf02391cfef_v2.py +4 -10
  115. fractal_server/migrations/versions/70e77f1c38b0_add_applyworkflow_first_task_index_and_.py +1 -0
  116. fractal_server/migrations/versions/71eefd1dd202_add_slurm_accounts.py +1 -1
  117. fractal_server/migrations/versions/7673fe18c05d_remove_project_dir_server_default.py +1 -1
  118. fractal_server/migrations/versions/7910eed4cf97_user_project_dirs_and_usergroup_viewer_.py +60 -0
  119. fractal_server/migrations/versions/791ce783d3d8_add_indices.py +1 -1
  120. fractal_server/migrations/versions/83bc2ad3ffcc_2_17_0.py +1 -0
  121. fractal_server/migrations/versions/84bf0fffde30_add_dumps_to_applyworkflow.py +1 -0
  122. fractal_server/migrations/versions/88270f589c9b_add_prevent_new_submissions.py +39 -0
  123. fractal_server/migrations/versions/8e8f227a3e36_update_taskv2_post_2_7_0.py +2 -4
  124. fractal_server/migrations/versions/8f79bd162e35_add_docs_info_and_docs_link_to_task_.py +1 -1
  125. fractal_server/migrations/versions/94a47ea2d3ff_remove_cache_dir_slurm_user_and_slurm_.py +1 -0
  126. fractal_server/migrations/versions/969d84257cac_add_historyrun_task_id.py +1 -1
  127. fractal_server/migrations/versions/97f444d47249_add_applyworkflow_project_dump.py +1 -1
  128. fractal_server/migrations/versions/981d588fe248_add_executor_error_log.py +1 -1
  129. fractal_server/migrations/versions/99ea79d9e5d2_add_dataset_history.py +2 -4
  130. fractal_server/migrations/versions/9c5ae74c9b98_add_user_settings_table.py +1 -1
  131. fractal_server/migrations/versions/9db60297b8b2_set_ondelete.py +1 -1
  132. fractal_server/migrations/versions/9fd26a2b0de4_add_workflow_timestamp_created.py +1 -1
  133. fractal_server/migrations/versions/a7f4d6137b53_add_workflow_dump_to_applyworkflow.py +1 -1
  134. fractal_server/migrations/versions/af1ef1c83c9b_add_accounting_tables.py +1 -0
  135. fractal_server/migrations/versions/af8673379a5c_drop_old_filter_columns.py +1 -0
  136. fractal_server/migrations/versions/b1e7f7a1ff71_task_group_for_pixi.py +1 -1
  137. fractal_server/migrations/versions/b3ffb095f973_json_to_jsonb.py +1 -0
  138. fractal_server/migrations/versions/bc0e8b3327a7_project_sharing.py +72 -0
  139. fractal_server/migrations/versions/c90a7c76e996_job_id_in_history_run.py +1 -1
  140. fractal_server/migrations/versions/caba9fb1ea5e_drop_useroauth_user_settings_id.py +1 -1
  141. fractal_server/migrations/versions/d256a7379ab8_taskgroup_activity_and_venv_info_to_.py +4 -9
  142. fractal_server/migrations/versions/d4fe3708d309_make_applyworkflow_workflow_dump_non_.py +1 -0
  143. fractal_server/migrations/versions/da2cb2ac4255_user_group_viewer_paths.py +1 -1
  144. fractal_server/migrations/versions/db09233ad13a_split_filters_and_keep_old_columns.py +1 -0
  145. fractal_server/migrations/versions/e0e717ae2f26_delete_linkuserproject_ondelete_project.py +50 -0
  146. fractal_server/migrations/versions/e75cac726012_make_applyworkflow_start_timestamp_not_.py +1 -0
  147. fractal_server/migrations/versions/e81103413827_add_job_type_filters.py +1 -1
  148. fractal_server/migrations/versions/efa89c30e0a4_add_project_timestamp_created.py +1 -0
  149. fractal_server/migrations/versions/f0702066b007_one_submitted_job_per_dataset.py +40 -0
  150. fractal_server/migrations/versions/f37aceb45062_make_historyunit_logfile_required.py +1 -1
  151. fractal_server/migrations/versions/f384e1c0cf5d_drop_task_default_args_columns.py +1 -0
  152. fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +4 -9
  153. fractal_server/runner/config/_local.py +8 -5
  154. fractal_server/runner/config/_slurm.py +39 -33
  155. fractal_server/runner/config/slurm_mem_to_MB.py +0 -1
  156. fractal_server/runner/executors/base_runner.py +29 -4
  157. fractal_server/runner/executors/local/get_local_config.py +1 -0
  158. fractal_server/runner/executors/local/runner.py +14 -13
  159. fractal_server/runner/executors/slurm_common/_batching.py +9 -20
  160. fractal_server/runner/executors/slurm_common/base_slurm_runner.py +53 -27
  161. fractal_server/runner/executors/slurm_common/get_slurm_config.py +14 -7
  162. fractal_server/runner/executors/slurm_common/remote.py +3 -1
  163. fractal_server/runner/executors/slurm_common/slurm_config.py +2 -0
  164. fractal_server/runner/executors/slurm_common/slurm_job_task_models.py +1 -3
  165. fractal_server/runner/executors/slurm_ssh/runner.py +16 -11
  166. fractal_server/runner/executors/slurm_ssh/tar_commands.py +1 -0
  167. fractal_server/runner/executors/slurm_sudo/_subprocess_run_as_user.py +1 -0
  168. fractal_server/runner/executors/slurm_sudo/runner.py +16 -11
  169. fractal_server/runner/task_files.py +9 -3
  170. fractal_server/runner/v2/_local.py +12 -6
  171. fractal_server/runner/v2/_slurm_ssh.py +14 -7
  172. fractal_server/runner/v2/_slurm_sudo.py +14 -7
  173. fractal_server/runner/v2/db_tools.py +0 -1
  174. fractal_server/runner/v2/deduplicate_list.py +2 -1
  175. fractal_server/runner/v2/runner.py +44 -28
  176. fractal_server/runner/v2/runner_functions.py +22 -28
  177. fractal_server/runner/v2/submit_workflow.py +29 -15
  178. fractal_server/ssh/_fabric.py +6 -13
  179. fractal_server/string_tools.py +0 -1
  180. fractal_server/syringe.py +1 -1
  181. fractal_server/tasks/config/_pixi.py +1 -1
  182. fractal_server/tasks/config/_python.py +16 -9
  183. fractal_server/tasks/utils.py +0 -1
  184. fractal_server/tasks/v2/local/_utils.py +3 -3
  185. fractal_server/tasks/v2/local/collect.py +15 -18
  186. fractal_server/tasks/v2/local/collect_pixi.py +14 -16
  187. fractal_server/tasks/v2/local/deactivate.py +14 -15
  188. fractal_server/tasks/v2/local/deactivate_pixi.py +7 -7
  189. fractal_server/tasks/v2/local/delete.py +6 -8
  190. fractal_server/tasks/v2/local/reactivate.py +12 -12
  191. fractal_server/tasks/v2/local/reactivate_pixi.py +12 -12
  192. fractal_server/tasks/v2/ssh/_utils.py +3 -3
  193. fractal_server/tasks/v2/ssh/collect.py +19 -24
  194. fractal_server/tasks/v2/ssh/collect_pixi.py +22 -24
  195. fractal_server/tasks/v2/ssh/deactivate.py +17 -15
  196. fractal_server/tasks/v2/ssh/deactivate_pixi.py +8 -7
  197. fractal_server/tasks/v2/ssh/delete.py +12 -10
  198. fractal_server/tasks/v2/ssh/reactivate.py +16 -16
  199. fractal_server/tasks/v2/ssh/reactivate_pixi.py +13 -14
  200. fractal_server/tasks/v2/templates/1_create_venv.sh +2 -0
  201. fractal_server/tasks/v2/templates/2_pip_install.sh +2 -0
  202. fractal_server/tasks/v2/templates/3_pip_freeze.sh +2 -0
  203. fractal_server/tasks/v2/templates/4_pip_show.sh +2 -0
  204. fractal_server/tasks/v2/templates/5_get_venv_size_and_file_number.sh +3 -1
  205. fractal_server/tasks/v2/templates/6_pip_install_from_freeze.sh +2 -0
  206. fractal_server/tasks/v2/templates/pixi_1_extract.sh +2 -0
  207. fractal_server/tasks/v2/templates/pixi_2_install.sh +2 -0
  208. fractal_server/tasks/v2/templates/pixi_3_post_install.sh +2 -0
  209. fractal_server/tasks/v2/utils_background.py +10 -10
  210. fractal_server/tasks/v2/utils_database.py +5 -5
  211. fractal_server/tasks/v2/utils_package_names.py +1 -2
  212. fractal_server/tasks/v2/utils_pixi.py +1 -3
  213. fractal_server/types/__init__.py +98 -1
  214. fractal_server/types/validators/__init__.py +3 -0
  215. fractal_server/types/validators/_common_validators.py +33 -3
  216. fractal_server/types/validators/_workflow_task_arguments_validators.py +1 -2
  217. fractal_server/utils.py +1 -0
  218. fractal_server/zip_tools.py +34 -0
  219. {fractal_server-2.17.1a1.dist-info → fractal_server-2.18.0.dist-info}/METADATA +3 -2
  220. fractal_server-2.18.0.dist-info/RECORD +275 -0
  221. fractal_server/app/routes/admin/v2/project.py +0 -41
  222. fractal_server-2.17.1a1.dist-info/RECORD +0 -264
  223. {fractal_server-2.17.1a1.dist-info → fractal_server-2.18.0.dist-info}/WHEEL +0 -0
  224. {fractal_server-2.17.1a1.dist-info → fractal_server-2.18.0.dist-info}/entry_points.txt +0 -0
  225. {fractal_server-2.17.1a1.dist-info → fractal_server-2.18.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,37 +1,39 @@
1
1
  """
2
2
  Auxiliary functions to get object from the database or perform simple checks
3
3
  """
4
+
4
5
  from typing import Any
5
- from typing import Literal
6
+ from typing import TypedDict
6
7
 
7
8
  from fastapi import HTTPException
8
9
  from fastapi import status
9
- from sqlalchemy.exc import MultipleResultsFound
10
10
  from sqlalchemy.orm.attributes import flag_modified
11
11
  from sqlmodel import select
12
12
  from sqlmodel.sql.expression import SelectOfScalar
13
13
 
14
- from ....models.v2 import DatasetV2
15
- from ....models.v2 import JobV2
16
- from ....models.v2 import LinkUserProjectV2
17
- from ....models.v2 import ProjectV2
18
- from ....models.v2 import TaskV2
19
- from ....models.v2 import WorkflowTaskV2
20
- from ....models.v2 import WorkflowV2
21
- from ....schemas.v2 import JobStatusTypeV2
22
14
  from fractal_server.app.db import AsyncSession
23
15
  from fractal_server.app.models import Profile
24
16
  from fractal_server.app.models import Resource
25
17
  from fractal_server.app.models import UserOAuth
18
+ from fractal_server.app.models.v2 import DatasetV2
19
+ from fractal_server.app.models.v2 import JobV2
20
+ from fractal_server.app.models.v2 import LinkUserProjectV2
21
+ from fractal_server.app.models.v2 import ProjectV2
22
+ from fractal_server.app.models.v2 import TaskV2
23
+ from fractal_server.app.models.v2 import WorkflowTaskV2
24
+ from fractal_server.app.models.v2 import WorkflowV2
25
+ from fractal_server.app.schemas.v2 import JobStatusType
26
+ from fractal_server.app.schemas.v2 import ProjectPermissions
26
27
  from fractal_server.logger import set_logger
27
28
 
28
29
  logger = set_logger(__name__)
29
30
 
30
31
 
31
- async def _get_project_check_owner(
32
+ async def _get_project_check_access(
32
33
  *,
33
34
  project_id: int,
34
35
  user_id: int,
36
+ required_permissions: ProjectPermissions,
35
37
  db: AsyncSession,
36
38
  ) -> ProjectV2:
37
39
  """
@@ -40,6 +42,7 @@ async def _get_project_check_owner(
40
42
  Args:
41
43
  project_id:
42
44
  user_id:
45
+ required_permissions:
43
46
  db:
44
47
 
45
48
  Returns:
@@ -47,31 +50,42 @@ async def _get_project_check_owner(
47
50
 
48
51
  Raises:
49
52
  HTTPException(status_code=403_FORBIDDEN):
50
- If the user is not a member of the project
53
+ - If the user is not a member of the project;
54
+ - If the user has not accepted the invitation yet;
55
+ - If the user has not the target permissions.
51
56
  HTTPException(status_code=404_NOT_FOUND):
52
57
  If the project does not exist
53
58
  """
54
59
  project = await db.get(ProjectV2, project_id)
55
-
56
- link_user_project = await db.get(LinkUserProjectV2, (project_id, user_id))
57
- if not project:
60
+ if project is None:
58
61
  raise HTTPException(
59
62
  status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
60
63
  )
61
- if not link_user_project:
64
+
65
+ link_user_project = await db.get(LinkUserProjectV2, (project_id, user_id))
66
+ if (
67
+ link_user_project is None
68
+ or not link_user_project.is_verified
69
+ or required_permissions not in link_user_project.permissions
70
+ ):
62
71
  raise HTTPException(
63
72
  status_code=status.HTTP_403_FORBIDDEN,
64
- detail=f"Not allowed on project {project_id}",
73
+ detail=(
74
+ "You are not authorized to perform this action. "
75
+ "If you think this is by mistake, "
76
+ "please contact the project owner."
77
+ ),
65
78
  )
66
79
 
67
80
  return project
68
81
 
69
82
 
70
- async def _get_workflow_check_owner(
83
+ async def _get_workflow_check_access(
71
84
  *,
72
85
  workflow_id: int,
73
86
  project_id: int,
74
87
  user_id: int,
88
+ required_permissions: ProjectPermissions,
75
89
  db: AsyncSession,
76
90
  ) -> WorkflowV2:
77
91
  """
@@ -88,39 +102,43 @@ async def _get_workflow_check_owner(
88
102
 
89
103
  Raises:
90
104
  HTTPException(status_code=404_NOT_FOUND):
91
- If the workflow does not exist
92
- HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
93
- If the workflow is not associated to the project
105
+ If the project or the workflow do not exist or if they are not
106
+ associated
107
+ HTTPException(status_code=403_FORBIDDEN):
108
+ If the user is not a member of the project
94
109
  """
95
110
 
96
111
  # Access control for project
97
- project = await _get_project_check_owner(
98
- project_id=project_id, user_id=user_id, db=db
112
+ await _get_project_check_access(
113
+ project_id=project_id,
114
+ user_id=user_id,
115
+ required_permissions=required_permissions,
116
+ db=db,
99
117
  )
100
118
 
101
- # Get workflow
102
- # (See issue 1087 for 'populate_existing=True')
103
- workflow = await db.get(WorkflowV2, workflow_id, populate_existing=True)
119
+ res = await db.execute(
120
+ select(WorkflowV2)
121
+ .where(WorkflowV2.id == workflow_id)
122
+ .where(WorkflowV2.project_id == project_id)
123
+ .execution_options(populate_existing=True) # See issue 1087
124
+ )
125
+ workflow = res.scalars().one_or_none()
104
126
 
105
127
  if not workflow:
106
128
  raise HTTPException(
107
129
  status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found"
108
130
  )
109
- if workflow.project_id != project.id:
110
- raise HTTPException(
111
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
112
- detail=(f"Invalid {project_id=} for {workflow_id=}."),
113
- )
114
131
 
115
132
  return workflow
116
133
 
117
134
 
118
- async def _get_workflow_task_check_owner(
135
+ async def _get_workflow_task_check_access(
119
136
  *,
120
137
  project_id: int,
121
138
  workflow_id: int,
122
139
  workflow_task_id: int,
123
140
  user_id: int,
141
+ required_permissions: ProjectPermissions,
124
142
  db: AsyncSession,
125
143
  ) -> tuple[WorkflowTaskV2, WorkflowV2]:
126
144
  """
@@ -138,34 +156,34 @@ async def _get_workflow_task_check_owner(
138
156
 
139
157
  Raises:
140
158
  HTTPException(status_code=404_NOT_FOUND):
141
- If the WorkflowTask does not exist
142
- HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
143
- If the WorkflowTask is not associated to the Workflow
159
+ If the project, the workflow or the workflowtask do not exist or
160
+ if they are not associated
161
+ HTTPException(status_code=403_FORBIDDEN):
162
+ If the user is not a member of the project
144
163
  """
145
164
 
146
165
  # Access control for workflow
147
- workflow = await _get_workflow_check_owner(
166
+ workflow = await _get_workflow_check_access(
148
167
  workflow_id=workflow_id,
149
168
  project_id=project_id,
150
169
  user_id=user_id,
170
+ required_permissions=required_permissions,
151
171
  db=db,
152
172
  )
153
173
 
154
- # If WorkflowTask is not in the db, exit
155
- workflow_task = await db.get(WorkflowTaskV2, workflow_task_id)
156
- if not workflow_task:
174
+ res = await db.execute(
175
+ select(WorkflowTaskV2)
176
+ .where(WorkflowTaskV2.id == workflow_task_id)
177
+ .where(WorkflowTaskV2.workflow_id == workflow_id)
178
+ )
179
+ workflow_task = res.scalars().one_or_none()
180
+
181
+ if workflow_task is None:
157
182
  raise HTTPException(
158
183
  status_code=status.HTTP_404_NOT_FOUND,
159
184
  detail="WorkflowTask not found",
160
185
  )
161
186
 
162
- # If WorkflowTask is not part of the expected Workflow, exit
163
- if workflow_id != workflow_task.workflow_id:
164
- raise HTTPException(
165
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
166
- detail=f"Invalid {workflow_id=} for {workflow_task_id=}",
167
- )
168
-
169
187
  return workflow_task, workflow
170
188
 
171
189
 
@@ -220,9 +238,10 @@ async def _check_project_exists(
220
238
  """
221
239
  stm = (
222
240
  select(ProjectV2)
223
- .join(LinkUserProjectV2)
241
+ .join(LinkUserProjectV2, LinkUserProjectV2.project_id == ProjectV2.id)
224
242
  .where(ProjectV2.name == project_name)
225
243
  .where(LinkUserProjectV2.user_id == user_id)
244
+ .where(LinkUserProjectV2.is_owner.is_(True))
226
245
  )
227
246
  res = await db.execute(stm)
228
247
  if res.scalars().all():
@@ -232,13 +251,19 @@ async def _check_project_exists(
232
251
  )
233
252
 
234
253
 
235
- async def _get_dataset_check_owner(
254
+ class DatasetOrProject(TypedDict):
255
+ dataset: DatasetV2
256
+ project: ProjectV2
257
+
258
+
259
+ async def _get_dataset_check_access(
236
260
  *,
237
261
  project_id: int,
238
262
  dataset_id: int,
239
263
  user_id: int,
264
+ required_permissions: ProjectPermissions,
240
265
  db: AsyncSession,
241
- ) -> dict[Literal["dataset", "project"], DatasetV2 | ProjectV2]:
266
+ ) -> DatasetOrProject:
242
267
  """
243
268
  Get a dataset and a project, after access control on the project
244
269
 
@@ -253,38 +278,49 @@ async def _get_dataset_check_owner(
253
278
  `project`).
254
279
 
255
280
  Raises:
256
- HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
257
- If the dataset is not associated to the project
281
+ HTTPException(status_code=404_UNPROCESSABLE_ENTITY):
282
+ If the project or the dataset do not exist or if they are not
283
+ associated
284
+ HTTPException(status_code=403_FORBIDDEN):
285
+ If the user is not a member of the project
258
286
  """
259
287
  # Access control for project
260
- project = await _get_project_check_owner(
261
- project_id=project_id, user_id=user_id, db=db
288
+ project = await _get_project_check_access(
289
+ project_id=project_id,
290
+ user_id=user_id,
291
+ required_permissions=required_permissions,
292
+ db=db,
262
293
  )
263
294
 
264
- # Get dataset
265
- # (See issue 1087 for 'populate_existing=True')
266
- dataset = await db.get(DatasetV2, dataset_id, populate_existing=True)
295
+ res = await db.execute(
296
+ select(DatasetV2)
297
+ .where(DatasetV2.id == dataset_id)
298
+ .where(DatasetV2.project_id == project_id)
299
+ .execution_options(populate_existing=True) # See issue 1087
300
+ )
301
+ dataset = res.scalars().one_or_none()
267
302
 
268
- if not dataset:
303
+ if dataset is None:
269
304
  raise HTTPException(
270
305
  status_code=status.HTTP_404_NOT_FOUND, detail="Dataset not found"
271
306
  )
272
- if dataset.project_id != project_id:
273
- raise HTTPException(
274
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
275
- detail=f"Invalid {project_id=} for {dataset_id=}",
276
- )
277
307
 
278
308
  return dict(dataset=dataset, project=project)
279
309
 
280
310
 
281
- async def _get_job_check_owner(
311
+ class JobAndProject(TypedDict):
312
+ job: JobV2
313
+ project: ProjectV2
314
+
315
+
316
+ async def _get_job_check_access(
282
317
  *,
283
318
  project_id: int,
284
319
  job_id: int,
285
320
  user_id: int,
321
+ required_permissions: ProjectPermissions,
286
322
  db: AsyncSession,
287
- ) -> dict[Literal["job", "project"], JobV2 | ProjectV2]:
323
+ ) -> JobAndProject:
288
324
  """
289
325
  Get a job and a project, after access control on the project
290
326
 
@@ -299,26 +335,32 @@ async def _get_job_check_owner(
299
335
  `project`).
300
336
 
301
337
  Raises:
302
- HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
303
- If the job is not associated to the project
338
+ HTTPException(status_code=404_UNPROCESSABLE_ENTITY):
339
+ If the project or the job do not exist or if they are not
340
+ associated
341
+ HTTPException(status_code=403_FORBIDDEN):
342
+ If the user is not a member of the project
304
343
  """
305
344
  # Access control for project
306
- project = await _get_project_check_owner(
345
+ project = await _get_project_check_access(
307
346
  project_id=project_id,
308
347
  user_id=user_id,
348
+ required_permissions=required_permissions,
309
349
  db=db,
310
350
  )
311
- # Get dataset
312
- job = await db.get(JobV2, job_id)
313
- if not job:
351
+
352
+ res = await db.execute(
353
+ select(JobV2)
354
+ .where(JobV2.id == job_id)
355
+ .where(JobV2.project_id == project_id)
356
+ )
357
+ job = res.scalars().one_or_none()
358
+
359
+ if job is None:
314
360
  raise HTTPException(
315
361
  status_code=status.HTTP_404_NOT_FOUND, detail="Job not found"
316
362
  )
317
- if job.project_id != project_id:
318
- raise HTTPException(
319
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
320
- detail=f"Invalid {project_id=} for {job_id=}",
321
- )
363
+
322
364
  return dict(job=job, project=project)
323
365
 
324
366
 
@@ -328,7 +370,7 @@ def _get_submitted_jobs_statement() -> SelectOfScalar:
328
370
  A sqlmodel statement that selects all `Job`s with
329
371
  `Job.status` equal to `submitted`.
330
372
  """
331
- stm = select(JobV2).where(JobV2.status == JobStatusTypeV2.SUBMITTED)
373
+ stm = select(JobV2).where(JobV2.status == JobStatusType.SUBMITTED)
332
374
  return stm
333
375
 
334
376
 
@@ -338,7 +380,7 @@ async def _workflow_has_submitted_job(
338
380
  ) -> bool:
339
381
  res = await db.execute(
340
382
  select(JobV2.id)
341
- .where(JobV2.status == JobStatusTypeV2.SUBMITTED)
383
+ .where(JobV2.status == JobStatusType.SUBMITTED)
342
384
  .where(JobV2.workflow_id == workflow_id)
343
385
  .limit(1)
344
386
  )
@@ -411,14 +453,18 @@ async def _workflow_insert_task(
411
453
  flag_modified(db_workflow, "task_list")
412
454
  await db.commit()
413
455
 
414
- # See issue 1087 for 'populate_existing=True'
415
- wf_task = await db.get(WorkflowTaskV2, wf_task.id, populate_existing=True)
456
+ wf_task = await db.get(
457
+ WorkflowTaskV2,
458
+ wf_task.id,
459
+ populate_existing=True, # See issue 1087
460
+ )
416
461
 
417
462
  return wf_task
418
463
 
419
464
 
420
- async def clean_app_job_list_v2(
421
- db: AsyncSession, jobs_list: list[int]
465
+ async def clean_app_job_list(
466
+ db: AsyncSession,
467
+ jobs_list: list[int],
422
468
  ) -> list[int]:
423
469
  """
424
470
  Remove from a job list all jobs with status different from submitted.
@@ -430,14 +476,14 @@ async def clean_app_job_list_v2(
430
476
  Return:
431
477
  List of IDs for submitted jobs.
432
478
  """
479
+ logger.info(f"[clean_app_job_list] START - {jobs_list=}.")
433
480
  stmt = select(JobV2).where(JobV2.id.in_(jobs_list))
434
481
  result = await db.execute(stmt)
435
482
  db_jobs_list = result.scalars().all()
436
483
  submitted_job_ids = [
437
- job.id
438
- for job in db_jobs_list
439
- if job.status == JobStatusTypeV2.SUBMITTED
484
+ job.id for job in db_jobs_list if job.status == JobStatusType.SUBMITTED
440
485
  ]
486
+ logger.info(f"[clean_app_job_list] END - {submitted_job_ids=}.")
441
487
  return submitted_job_ids
442
488
 
443
489
 
@@ -507,49 +553,11 @@ async def _get_workflowtask_or_404(
507
553
  return wftask
508
554
 
509
555
 
510
- async def _get_submitted_job_or_none(
511
- *,
512
- dataset_id: int,
513
- workflow_id: int,
514
- db: AsyncSession,
515
- ) -> JobV2 | None:
516
- """
517
- Get the submitted job for given dataset/workflow, if any.
518
-
519
- This function also handles the invalid branch where more than one job
520
- is found.
521
-
522
- Args:
523
- dataset_id:
524
- workflow_id:
525
- db:
526
- """
527
- res = await db.execute(
528
- _get_submitted_jobs_statement()
529
- .where(JobV2.dataset_id == dataset_id)
530
- .where(JobV2.workflow_id == workflow_id)
531
- )
532
- try:
533
- return res.scalars().one_or_none()
534
- except MultipleResultsFound as e:
535
- error_msg = (
536
- "Multiple running jobs found for "
537
- f"{dataset_id=} and {workflow_id=}."
538
- )
539
- logger.error(f"{error_msg} Original error: {str(e)}.")
540
- raise HTTPException(
541
- status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
542
- detail=error_msg,
543
- )
544
-
545
-
546
556
  async def _get_user_resource_id(user_id: int, db: AsyncSession) -> int | None:
547
557
  res = await db.execute(
548
558
  select(Resource.id)
549
- .join(Profile)
550
- .join(UserOAuth)
551
- .where(Resource.id == Profile.resource_id)
552
- .where(Profile.id == UserOAuth.profile_id)
559
+ .join(Profile, Resource.id == Profile.resource_id)
560
+ .join(UserOAuth, Profile.id == UserOAuth.profile_id)
553
561
  .where(UserOAuth.id == user_id)
554
562
  )
555
563
  resource_id = res.scalar_one_or_none()
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from pathlib import Path
2
3
  from typing import Literal
3
4
 
@@ -12,16 +13,15 @@ from fractal_server.app.models.v2 import HistoryUnit
12
13
  from fractal_server.app.models.v2 import WorkflowV2
13
14
  from fractal_server.app.routes.api.v2._aux_functions import _get_dataset_or_404
14
15
  from fractal_server.app.routes.api.v2._aux_functions import (
15
- _get_project_check_owner,
16
- )
17
- from fractal_server.app.routes.api.v2._aux_functions import (
18
- _get_workflow_or_404,
16
+ _get_project_check_access,
19
17
  )
18
+ from fractal_server.app.routes.api.v2._aux_functions import _get_workflow_or_404
20
19
  from fractal_server.app.routes.api.v2._aux_functions import (
21
20
  _get_workflowtask_or_404,
22
21
  )
22
+ from fractal_server.app.schemas.v2.sharing import ProjectPermissions
23
23
  from fractal_server.logger import set_logger
24
-
24
+ from fractal_server.zip_tools import _read_single_file_from_zip
25
25
 
26
26
  logger = set_logger(__name__)
27
27
 
@@ -66,27 +66,51 @@ async def get_history_run_or_404(
66
66
 
67
67
  def read_log_file(
68
68
  *,
69
- logfile: str | None,
70
- wftask: WorkflowTaskV2,
69
+ task_name: str,
71
70
  dataset_id: int,
72
- ):
73
- if logfile is None or not Path(logfile).exists():
74
- logger.debug(
75
- f"Logs for task '{wftask.task.name}' in dataset "
76
- f"{dataset_id} are not available ({logfile=})."
77
- )
78
- return (
79
- f"Logs for task '{wftask.task.name}' in dataset "
80
- f"{dataset_id} are not available."
81
- )
71
+ logfile: str,
72
+ job_working_dir: str,
73
+ ) -> str:
74
+ """
75
+ Returns the contents of a Job's log file, either directly from the working
76
+ directory or from the corresponding ZIP archive.
77
+
78
+ The function first checks if `logfile` exists on disk.
82
79
 
80
+ If not, it checks if the Job working directory has been zipped and tries to
81
+ read `logfile` from within the archive.
82
+ (Note: it is assumed that `logfile` is relative to `job_working_dir`)
83
+ """
84
+ archive_path = os.path.normpath(job_working_dir) + ".zip"
83
85
  try:
84
- with open(logfile) as f:
85
- return f.read()
86
+ if Path(logfile).exists():
87
+ with open(logfile) as f:
88
+ return f.read()
89
+ elif Path(archive_path).exists():
90
+ relative_logfile = (
91
+ Path(logfile).relative_to(job_working_dir).as_posix()
92
+ )
93
+ return _read_single_file_from_zip(
94
+ file_path=relative_logfile, archive_path=archive_path
95
+ )
96
+
97
+ else:
98
+ logger.error(
99
+ f"Error while retrieving logs for {logfile=} and "
100
+ f"{archive_path=}: both files do not exist."
101
+ )
102
+ return (
103
+ f"Logs for task '{task_name}' in dataset "
104
+ f"{dataset_id} are not available."
105
+ )
86
106
  except Exception as e:
107
+ logger.error(
108
+ f"Error while retrieving logs for {logfile=} and {archive_path=}. "
109
+ f"Original error: {str(e)}"
110
+ )
87
111
  return (
88
- f"Error while retrieving logs for task '{wftask.task.name}' "
89
- f"in dataset {dataset_id}. Original error: {str(e)}."
112
+ f"Error while retrieving logs for task '{task_name}' "
113
+ f"in dataset {dataset_id}."
90
114
  )
91
115
 
92
116
 
@@ -96,6 +120,7 @@ async def _verify_workflow_and_dataset_access(
96
120
  workflow_id: int,
97
121
  dataset_id: int,
98
122
  user_id: int,
123
+ required_permissions: ProjectPermissions,
99
124
  db: AsyncSession,
100
125
  ) -> dict[Literal["dataset", "workflow"], DatasetV2 | WorkflowV2]:
101
126
  """
@@ -108,9 +133,10 @@ async def _verify_workflow_and_dataset_access(
108
133
  user_id:
109
134
  db:
110
135
  """
111
- await _get_project_check_owner(
136
+ await _get_project_check_access(
112
137
  project_id=project_id,
113
138
  user_id=user_id,
139
+ required_permissions=required_permissions,
114
140
  db=db,
115
141
  )
116
142
  workflow = await _get_workflow_or_404(
@@ -135,12 +161,13 @@ async def _verify_workflow_and_dataset_access(
135
161
  return dict(dataset=dataset, workflow=workflow)
136
162
 
137
163
 
138
- async def get_wftask_check_owner(
164
+ async def get_wftask_check_access(
139
165
  *,
140
166
  project_id: int,
141
167
  dataset_id: int,
142
168
  workflowtask_id: int,
143
169
  user_id: int,
170
+ required_permissions: ProjectPermissions,
144
171
  db: AsyncSession,
145
172
  ) -> WorkflowTaskV2:
146
173
  """
@@ -161,6 +188,7 @@ async def get_wftask_check_owner(
161
188
  project_id=project_id,
162
189
  dataset_id=dataset_id,
163
190
  workflow_id=wftask.workflow_id,
191
+ required_permissions=required_permissions,
164
192
  user_id=user_id,
165
193
  db=db,
166
194
  )
@@ -0,0 +1,97 @@
1
+ from fastapi import HTTPException
2
+ from fastapi import status
3
+ from sqlmodel import select
4
+
5
+ from fractal_server.app.db import AsyncSession
6
+ from fractal_server.app.models import UserOAuth
7
+ from fractal_server.app.models.v2 import LinkUserProjectV2
8
+
9
+
10
+ async def raise_403_if_not_owner(
11
+ *,
12
+ user_id: int,
13
+ project_id: int,
14
+ db: AsyncSession,
15
+ ) -> None:
16
+ """
17
+ Raises 403 if User[`user_id`] is not owner of Project[`project_id`],
18
+ regardless of whether the User or Project exists.
19
+ """
20
+ res = await db.execute(
21
+ select(LinkUserProjectV2)
22
+ .where(LinkUserProjectV2.project_id == project_id)
23
+ .where(LinkUserProjectV2.user_id == user_id)
24
+ .where(LinkUserProjectV2.is_owner.is_(True))
25
+ )
26
+ link = res.scalars().one_or_none()
27
+ if link is None:
28
+ raise HTTPException(
29
+ status_code=status.HTTP_403_FORBIDDEN,
30
+ detail="Current user is not the project owner.",
31
+ )
32
+ return link
33
+
34
+
35
+ async def get_link_or_404(
36
+ *, user_id: int, project_id: int, db: AsyncSession
37
+ ) -> LinkUserProjectV2:
38
+ """
39
+ Raises 404 if User[`user_id`] is not linked to Project[`project_id`],
40
+ regardless of whether the User or Project exists.
41
+ """
42
+ link = await db.get(LinkUserProjectV2, (project_id, user_id))
43
+ if link is None:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_404_NOT_FOUND,
46
+ detail="User is not linked to project.",
47
+ )
48
+ return link
49
+
50
+
51
+ async def get_pending_invitation_or_404(
52
+ *, user_id: int, project_id: int, db: AsyncSession
53
+ ) -> LinkUserProjectV2:
54
+ """
55
+ Raises 404 if User[`user_id`] has not a pending invitation to
56
+ Project[`project_id`], regardless of whether the User or Project exists.
57
+ """
58
+ link = await get_link_or_404(user_id=user_id, project_id=project_id, db=db)
59
+ if link.is_verified:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_404_NOT_FOUND,
62
+ detail="No pending invitation for user on this project.",
63
+ )
64
+ return link
65
+
66
+
67
+ async def raise_422_if_link_exists(
68
+ *, user_id: int, project_id: int, db: AsyncSession
69
+ ) -> None:
70
+ """
71
+ Raises 422 if User[`user_id`] is linked Project[`project_id`], regardless
72
+ of whether the User or Project exists.
73
+ """
74
+ link = await db.get(LinkUserProjectV2, (project_id, user_id))
75
+ if link is not None:
76
+ raise HTTPException(
77
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
78
+ detail="User is already associated to project.",
79
+ )
80
+ return
81
+
82
+
83
+ async def get_user_id_from_email_or_404(
84
+ *, user_email: str, db: AsyncSession
85
+ ) -> int:
86
+ """
87
+ Raises 404 if there is no User with email `user_email`.
88
+ """
89
+ res = await db.execute(
90
+ select(UserOAuth.id).where(UserOAuth.email == user_email)
91
+ )
92
+ user_id = res.scalar_one_or_none()
93
+ if user_id is None:
94
+ raise HTTPException(
95
+ status_code=status.HTTP_404_NOT_FOUND, detail="User not found."
96
+ )
97
+ return user_id