fractal-server 2.16.5__py3-none-any.whl → 2.17.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 (143) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +178 -52
  3. fractal_server/app/db/__init__.py +9 -11
  4. fractal_server/app/models/security.py +30 -22
  5. fractal_server/app/models/user_settings.py +5 -4
  6. fractal_server/app/models/v2/__init__.py +4 -0
  7. fractal_server/app/models/v2/job.py +3 -4
  8. fractal_server/app/models/v2/profile.py +16 -0
  9. fractal_server/app/models/v2/project.py +5 -0
  10. fractal_server/app/models/v2/resource.py +130 -0
  11. fractal_server/app/models/v2/task_group.py +4 -0
  12. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  13. fractal_server/app/routes/admin/v2/_aux_functions.py +55 -0
  14. fractal_server/app/routes/admin/v2/accounting.py +3 -3
  15. fractal_server/app/routes/admin/v2/impersonate.py +2 -2
  16. fractal_server/app/routes/admin/v2/job.py +51 -15
  17. fractal_server/app/routes/admin/v2/profile.py +100 -0
  18. fractal_server/app/routes/admin/v2/project.py +2 -2
  19. fractal_server/app/routes/admin/v2/resource.py +222 -0
  20. fractal_server/app/routes/admin/v2/task.py +59 -32
  21. fractal_server/app/routes/admin/v2/task_group.py +17 -12
  22. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +52 -86
  23. fractal_server/app/routes/api/__init__.py +45 -8
  24. fractal_server/app/routes/api/v2/_aux_functions.py +17 -1
  25. fractal_server/app/routes/api/v2/_aux_functions_history.py +2 -2
  26. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
  27. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +55 -19
  28. fractal_server/app/routes/api/v2/_aux_task_group_disambiguation.py +21 -17
  29. fractal_server/app/routes/api/v2/dataset.py +10 -19
  30. fractal_server/app/routes/api/v2/history.py +8 -8
  31. fractal_server/app/routes/api/v2/images.py +5 -5
  32. fractal_server/app/routes/api/v2/job.py +8 -8
  33. fractal_server/app/routes/api/v2/pre_submission_checks.py +3 -3
  34. fractal_server/app/routes/api/v2/project.py +15 -7
  35. fractal_server/app/routes/api/v2/status_legacy.py +2 -2
  36. fractal_server/app/routes/api/v2/submit.py +49 -42
  37. fractal_server/app/routes/api/v2/task.py +26 -8
  38. fractal_server/app/routes/api/v2/task_collection.py +39 -50
  39. fractal_server/app/routes/api/v2/task_collection_custom.py +10 -6
  40. fractal_server/app/routes/api/v2/task_collection_pixi.py +34 -42
  41. fractal_server/app/routes/api/v2/task_group.py +19 -9
  42. fractal_server/app/routes/api/v2/task_group_lifecycle.py +43 -86
  43. fractal_server/app/routes/api/v2/task_version_update.py +3 -3
  44. fractal_server/app/routes/api/v2/workflow.py +9 -9
  45. fractal_server/app/routes/api/v2/workflow_import.py +29 -16
  46. fractal_server/app/routes/api/v2/workflowtask.py +5 -5
  47. fractal_server/app/routes/auth/__init__.py +34 -5
  48. fractal_server/app/routes/auth/_aux_auth.py +39 -20
  49. fractal_server/app/routes/auth/current_user.py +56 -67
  50. fractal_server/app/routes/auth/group.py +29 -46
  51. fractal_server/app/routes/auth/oauth.py +55 -38
  52. fractal_server/app/routes/auth/register.py +2 -2
  53. fractal_server/app/routes/auth/router.py +4 -2
  54. fractal_server/app/routes/auth/users.py +29 -53
  55. fractal_server/app/routes/aux/_runner.py +2 -1
  56. fractal_server/app/routes/aux/validate_user_profile.py +62 -0
  57. fractal_server/app/schemas/__init__.py +0 -1
  58. fractal_server/app/schemas/user.py +43 -13
  59. fractal_server/app/schemas/user_group.py +2 -1
  60. fractal_server/app/schemas/v2/__init__.py +12 -0
  61. fractal_server/app/schemas/v2/profile.py +78 -0
  62. fractal_server/app/schemas/v2/resource.py +137 -0
  63. fractal_server/app/schemas/v2/task_collection.py +11 -3
  64. fractal_server/app/schemas/v2/task_group.py +5 -0
  65. fractal_server/app/security/__init__.py +174 -75
  66. fractal_server/app/security/signup_email.py +52 -34
  67. fractal_server/config/__init__.py +27 -0
  68. fractal_server/config/_data.py +68 -0
  69. fractal_server/config/_database.py +59 -0
  70. fractal_server/config/_email.py +133 -0
  71. fractal_server/config/_main.py +78 -0
  72. fractal_server/config/_oauth.py +69 -0
  73. fractal_server/config/_settings_config.py +7 -0
  74. fractal_server/data_migrations/2_17_0.py +339 -0
  75. fractal_server/images/tools.py +3 -3
  76. fractal_server/logger.py +3 -3
  77. fractal_server/main.py +17 -23
  78. fractal_server/migrations/naming_convention.py +1 -1
  79. fractal_server/migrations/versions/83bc2ad3ffcc_2_17_0.py +195 -0
  80. fractal_server/runner/config/__init__.py +2 -0
  81. fractal_server/runner/config/_local.py +21 -0
  82. fractal_server/runner/config/_slurm.py +129 -0
  83. fractal_server/runner/config/slurm_mem_to_MB.py +63 -0
  84. fractal_server/runner/exceptions.py +4 -0
  85. fractal_server/runner/executors/base_runner.py +17 -7
  86. fractal_server/runner/executors/local/get_local_config.py +21 -86
  87. fractal_server/runner/executors/local/runner.py +48 -5
  88. fractal_server/runner/executors/slurm_common/_batching.py +2 -2
  89. fractal_server/runner/executors/slurm_common/base_slurm_runner.py +60 -26
  90. fractal_server/runner/executors/slurm_common/get_slurm_config.py +39 -55
  91. fractal_server/runner/executors/slurm_common/remote.py +1 -1
  92. fractal_server/runner/executors/slurm_common/slurm_config.py +214 -0
  93. fractal_server/runner/executors/slurm_common/slurm_job_task_models.py +1 -1
  94. fractal_server/runner/executors/slurm_ssh/runner.py +12 -14
  95. fractal_server/runner/executors/slurm_sudo/_subprocess_run_as_user.py +2 -2
  96. fractal_server/runner/executors/slurm_sudo/runner.py +12 -12
  97. fractal_server/runner/v2/_local.py +36 -21
  98. fractal_server/runner/v2/_slurm_ssh.py +41 -4
  99. fractal_server/runner/v2/_slurm_sudo.py +42 -12
  100. fractal_server/runner/v2/db_tools.py +1 -1
  101. fractal_server/runner/v2/runner.py +3 -11
  102. fractal_server/runner/v2/runner_functions.py +42 -28
  103. fractal_server/runner/v2/submit_workflow.py +88 -109
  104. fractal_server/runner/versions.py +8 -3
  105. fractal_server/ssh/_fabric.py +6 -6
  106. fractal_server/tasks/config/__init__.py +3 -0
  107. fractal_server/tasks/config/_pixi.py +127 -0
  108. fractal_server/tasks/config/_python.py +51 -0
  109. fractal_server/tasks/v2/local/_utils.py +7 -7
  110. fractal_server/tasks/v2/local/collect.py +13 -5
  111. fractal_server/tasks/v2/local/collect_pixi.py +26 -10
  112. fractal_server/tasks/v2/local/deactivate.py +7 -1
  113. fractal_server/tasks/v2/local/deactivate_pixi.py +5 -1
  114. fractal_server/tasks/v2/local/delete.py +5 -1
  115. fractal_server/tasks/v2/local/reactivate.py +13 -5
  116. fractal_server/tasks/v2/local/reactivate_pixi.py +27 -9
  117. fractal_server/tasks/v2/ssh/_pixi_slurm_ssh.py +11 -10
  118. fractal_server/tasks/v2/ssh/_utils.py +6 -7
  119. fractal_server/tasks/v2/ssh/collect.py +19 -12
  120. fractal_server/tasks/v2/ssh/collect_pixi.py +34 -16
  121. fractal_server/tasks/v2/ssh/deactivate.py +12 -8
  122. fractal_server/tasks/v2/ssh/deactivate_pixi.py +14 -10
  123. fractal_server/tasks/v2/ssh/delete.py +12 -9
  124. fractal_server/tasks/v2/ssh/reactivate.py +18 -12
  125. fractal_server/tasks/v2/ssh/reactivate_pixi.py +36 -17
  126. fractal_server/tasks/v2/templates/4_pip_show.sh +4 -6
  127. fractal_server/tasks/v2/utils_database.py +2 -2
  128. fractal_server/tasks/v2/utils_pixi.py +3 -0
  129. fractal_server/tasks/v2/utils_python_interpreter.py +8 -16
  130. fractal_server/tasks/v2/utils_templates.py +7 -10
  131. fractal_server/utils.py +1 -1
  132. {fractal_server-2.16.5.dist-info → fractal_server-2.17.0.dist-info}/METADATA +8 -10
  133. {fractal_server-2.16.5.dist-info → fractal_server-2.17.0.dist-info}/RECORD +137 -118
  134. {fractal_server-2.16.5.dist-info → fractal_server-2.17.0.dist-info}/WHEEL +1 -1
  135. fractal_server/app/routes/aux/validate_user_settings.py +0 -73
  136. fractal_server/app/schemas/user_settings.py +0 -67
  137. fractal_server/app/user_settings.py +0 -42
  138. fractal_server/config.py +0 -906
  139. fractal_server/data_migrations/2_14_10.py +0 -48
  140. fractal_server/runner/executors/slurm_common/_slurm_config.py +0 -471
  141. /fractal_server/{runner → app}/shutdown.py +0 -0
  142. {fractal_server-2.16.5.dist-info → fractal_server-2.17.0.dist-info}/entry_points.txt +0 -0
  143. {fractal_server-2.16.5.dist-info → fractal_server-2.17.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,50 +1,66 @@
1
1
  from fastapi import APIRouter
2
+ from httpx_oauth.clients.github import GitHubOAuth2
3
+ from httpx_oauth.clients.google import GoogleOAuth2
4
+ from httpx_oauth.clients.openid import OpenID
5
+ from httpx_oauth.clients.openid import OpenIDConfigurationError
2
6
 
3
7
  from . import cookie_backend
4
8
  from . import fastapi_users
5
- from ....config import get_settings
6
- from ....syringe import Inject
9
+ from fractal_server.config import get_oauth_settings
10
+ from fractal_server.config import get_settings
11
+ from fractal_server.config import OAuthSettings
12
+ from fractal_server.syringe import Inject
7
13
 
8
- router_oauth = APIRouter()
9
14
 
15
+ def _create_client_github(cfg: OAuthSettings) -> GitHubOAuth2:
16
+ return GitHubOAuth2(
17
+ client_id=cfg.OAUTH_CLIENT_ID.get_secret_value(),
18
+ client_secret=cfg.OAUTH_CLIENT_SECRET.get_secret_value(),
19
+ )
10
20
 
11
- # OAUTH CLIENTS
12
21
 
13
- # NOTE: settings.OAUTH_CLIENTS are collected by
14
- # Settings.collect_oauth_clients(). If no specific client is specified in the
15
- # environment variables (e.g. by setting OAUTH_FOO_CLIENT_ID and
16
- # OAUTH_FOO_CLIENT_SECRET), this list is empty
22
+ def _create_client_google(cfg: OAuthSettings) -> GoogleOAuth2:
23
+ return GoogleOAuth2(
24
+ client_id=cfg.OAUTH_CLIENT_ID.get_secret_value(),
25
+ client_secret=cfg.OAUTH_CLIENT_SECRET.get_secret_value(),
26
+ )
17
27
 
18
- # FIXME:Dependency injection should be wrapped within a function call to make
19
- # it truly lazy. This function could then be called on startup of the FastAPI
20
- # app (cf. fractal_server.main)
21
- settings = Inject(get_settings)
22
28
 
23
- for client_config in settings.OAUTH_CLIENTS_CONFIG:
24
- client_name = client_config.CLIENT_NAME.lower()
29
+ def _create_client_oidc(cfg: OAuthSettings) -> OpenID:
30
+ try:
31
+ open_id = OpenID(
32
+ client_id=cfg.OAUTH_CLIENT_ID.get_secret_value(),
33
+ client_secret=cfg.OAUTH_CLIENT_SECRET.get_secret_value(),
34
+ openid_configuration_endpoint=cfg.OAUTH_OIDC_CONFIG_ENDPOINT.get_secret_value(), # noqa
35
+ )
36
+ except OpenIDConfigurationError as e:
37
+ OAUTH_OIDC_CONFIG_ENDPOINT = (
38
+ cfg.OAUTH_OIDC_CONFIG_ENDPOINT.get_secret_value()
39
+ )
40
+ raise RuntimeError(
41
+ f"Cannot initialize OpenID client. Original error: '{e}'. "
42
+ f"Hint: is {OAUTH_OIDC_CONFIG_ENDPOINT=} reachable?"
43
+ )
44
+ return open_id
25
45
 
26
- if client_name == "google":
27
- from httpx_oauth.clients.google import GoogleOAuth2
28
46
 
29
- client = GoogleOAuth2(
30
- client_config.CLIENT_ID,
31
- client_config.CLIENT_SECRET.get_secret_value(),
32
- )
33
- elif client_name == "github":
34
- from httpx_oauth.clients.github import GitHubOAuth2
47
+ def get_oauth_router() -> APIRouter | None:
48
+ """
49
+ Get the `APIRouter` object for OAuth endpoints.
50
+ """
51
+ router_oauth = APIRouter()
52
+ settings = Inject(get_settings)
53
+ oauth_settings = Inject(get_oauth_settings)
54
+ if not oauth_settings.is_set:
55
+ return None
35
56
 
36
- client = GitHubOAuth2(
37
- client_config.CLIENT_ID,
38
- client_config.CLIENT_SECRET.get_secret_value(),
39
- )
57
+ client_name = oauth_settings.OAUTH_CLIENT_NAME
58
+ if client_name == "google":
59
+ client = _create_client_google(oauth_settings)
60
+ elif client_name == "github":
61
+ client = _create_client_github(oauth_settings)
40
62
  else:
41
- from httpx_oauth.clients.openid import OpenID
42
-
43
- client = OpenID(
44
- client_config.CLIENT_ID,
45
- client_config.CLIENT_SECRET.get_secret_value(),
46
- client_config.OIDC_CONFIGURATION_ENDPOINT,
47
- )
63
+ client = _create_client_oidc(oauth_settings)
48
64
 
49
65
  router_oauth.include_router(
50
66
  fastapi_users.get_oauth_router(
@@ -53,13 +69,14 @@ for client_config in settings.OAUTH_CLIENTS_CONFIG:
53
69
  settings.JWT_SECRET_KEY,
54
70
  is_verified_by_default=False,
55
71
  associate_by_email=True,
56
- redirect_url=client_config.REDIRECT_URL,
72
+ redirect_url=oauth_settings.OAUTH_REDIRECT_URL,
57
73
  ),
58
74
  prefix=f"/{client_name}",
59
75
  )
60
76
 
77
+ # Add trailing slash to all routes' paths
78
+ for route in router_oauth.routes:
79
+ if not route.path.endswith("/"):
80
+ route.path = f"{route.path}/"
61
81
 
62
- # Add trailing slash to all routes' paths
63
- for route in router_oauth.routes:
64
- if not route.path.endswith("/"):
65
- route.path = f"{route.path}/"
82
+ return router_oauth
@@ -4,7 +4,7 @@ Definition of `/auth/register/` routes.
4
4
  from fastapi import APIRouter
5
5
  from fastapi import Depends
6
6
 
7
- from . import current_active_superuser
7
+ from . import current_superuser_act
8
8
  from . import fastapi_users
9
9
  from ...schemas.user import UserCreate
10
10
  from ...schemas.user import UserRead
@@ -13,7 +13,7 @@ router_register = APIRouter()
13
13
 
14
14
  router_register.include_router(
15
15
  fastapi_users.get_register_router(UserRead, UserCreate),
16
- dependencies=[Depends(current_active_superuser)],
16
+ dependencies=[Depends(current_superuser_act)],
17
17
  )
18
18
 
19
19
 
@@ -3,7 +3,7 @@ from fastapi import APIRouter
3
3
  from .current_user import router_current_user
4
4
  from .group import router_group
5
5
  from .login import router_login
6
- from .oauth import router_oauth
6
+ from .oauth import get_oauth_router
7
7
  from .register import router_register
8
8
  from .users import router_users
9
9
 
@@ -14,4 +14,6 @@ router_auth.include_router(router_current_user)
14
14
  router_auth.include_router(router_login)
15
15
  router_auth.include_router(router_users)
16
16
  router_auth.include_router(router_group)
17
- router_auth.include_router(router_oauth)
17
+ router_oauth = get_oauth_router()
18
+ if router_oauth is not None:
19
+ router_auth.include_router(router_oauth)
@@ -6,31 +6,29 @@ from fastapi import Depends
6
6
  from fastapi import HTTPException
7
7
  from fastapi import status
8
8
  from fastapi_users import exceptions
9
- from fastapi_users import schemas
10
9
  from fastapi_users.router.common import ErrorCode
11
10
  from sqlalchemy.ext.asyncio import AsyncSession
12
11
  from sqlmodel import func
13
12
  from sqlmodel import select
14
13
 
15
- from . import current_active_superuser
14
+ from . import current_superuser_act
16
15
  from ...db import get_async_db
17
16
  from ...schemas.user import UserRead
18
17
  from ...schemas.user import UserUpdate
19
- from ..aux.validate_user_settings import verify_user_has_settings
20
- from ._aux_auth import _get_default_usergroup_id
18
+ from ._aux_auth import _get_default_usergroup_id_or_none
21
19
  from ._aux_auth import _get_single_user_with_groups
22
- from ._aux_auth import FRACTAL_DEFAULT_GROUP_NAME
23
20
  from fractal_server.app.models import LinkUserGroup
24
21
  from fractal_server.app.models import UserGroup
25
22
  from fractal_server.app.models import UserOAuth
26
- from fractal_server.app.models import UserSettings
23
+ from fractal_server.app.models.v2 import Profile
27
24
  from fractal_server.app.routes.auth._aux_auth import _user_or_404
28
- from fractal_server.app.schemas import UserSettingsRead
29
- from fractal_server.app.schemas import UserSettingsUpdate
30
25
  from fractal_server.app.schemas.user import UserUpdateGroups
31
26
  from fractal_server.app.security import get_user_manager
32
27
  from fractal_server.app.security import UserManager
28
+ from fractal_server.config import get_settings
33
29
  from fractal_server.logger import set_logger
30
+ from fractal_server.syringe import Inject
31
+
34
32
 
35
33
  router_users = APIRouter()
36
34
 
@@ -42,7 +40,7 @@ logger = set_logger(__name__)
42
40
  async def get_user(
43
41
  user_id: int,
44
42
  group_ids_names: bool = True,
45
- superuser: UserOAuth = Depends(current_active_superuser),
43
+ superuser: UserOAuth = Depends(current_superuser_act),
46
44
  db: AsyncSession = Depends(get_async_db),
47
45
  ) -> UserRead:
48
46
  user = await _user_or_404(user_id, db)
@@ -56,7 +54,7 @@ async def get_user(
56
54
  async def patch_user(
57
55
  user_id: int,
58
56
  user_update: UserUpdate,
59
- current_superuser: UserOAuth = Depends(current_active_superuser),
57
+ current_superuser: UserOAuth = Depends(current_superuser_act),
60
58
  user_manager: UserManager = Depends(get_user_manager),
61
59
  db: AsyncSession = Depends(get_async_db),
62
60
  ):
@@ -67,6 +65,14 @@ async def patch_user(
67
65
  # Check that user exists
68
66
  user_to_patch = await _user_or_404(user_id, db)
69
67
 
68
+ if user_update.profile_id is not None:
69
+ profile = await db.get(Profile, user_update.profile_id)
70
+ if profile is None:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_404_NOT_FOUND,
73
+ detail=f"Profile {user_update.profile_id} not found.",
74
+ )
75
+
70
76
  # Modify user attributes
71
77
  try:
72
78
  user = await user_manager.update(
@@ -75,7 +81,7 @@ async def patch_user(
75
81
  safe=False,
76
82
  request=None,
77
83
  )
78
- validated_user = schemas.model_validate(UserOAuth, user.model_dump())
84
+ validated_user = UserOAuth.model_validate(user.model_dump())
79
85
  patched_user = await db.get(
80
86
  UserOAuth, validated_user.id, populate_existing=True
81
87
  )
@@ -103,13 +109,16 @@ async def patch_user(
103
109
 
104
110
  @router_users.get("/users/", response_model=list[UserRead])
105
111
  async def list_users(
106
- user: UserOAuth = Depends(current_active_superuser),
112
+ profile_id: int | None = None,
113
+ user: UserOAuth = Depends(current_superuser_act),
107
114
  db: AsyncSession = Depends(get_async_db),
108
115
  ):
109
116
  """
110
117
  Return list of all users
111
118
  """
112
119
  stm = select(UserOAuth)
120
+ if profile_id is not None:
121
+ stm = stm.where(UserOAuth.profile_id == profile_id)
113
122
  res = await db.execute(stm)
114
123
  user_list = res.scalars().unique().all()
115
124
 
@@ -136,9 +145,10 @@ async def list_users(
136
145
  async def set_user_groups(
137
146
  user_id: int,
138
147
  user_update: UserUpdateGroups,
139
- superuser: UserOAuth = Depends(current_active_superuser),
148
+ superuser: UserOAuth = Depends(current_superuser_act),
140
149
  db: AsyncSession = Depends(get_async_db),
141
150
  ) -> UserRead:
151
+ settings = Inject(get_settings)
142
152
  # Preliminary check that all objects exist in the db
143
153
  user = await _user_or_404(user_id=user_id, db=db)
144
154
  target_group_ids = user_update.group_ids
@@ -154,13 +164,16 @@ async def set_user_groups(
154
164
  )
155
165
 
156
166
  # Check that default group is not being removed
157
- default_group_id = await _get_default_usergroup_id(db=db)
158
- if default_group_id not in target_group_ids:
167
+ default_group_id_or_none = await _get_default_usergroup_id_or_none(db=db)
168
+ if (
169
+ default_group_id_or_none is not None
170
+ and default_group_id_or_none not in target_group_ids
171
+ ):
159
172
  raise HTTPException(
160
173
  status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
161
174
  detail=(
162
175
  f"Cannot remove user from "
163
- f"'{FRACTAL_DEFAULT_GROUP_NAME}' group.",
176
+ f"'{settings.FRACTAL_DEFAULT_GROUP_NAME}' group.",
164
177
  ),
165
178
  )
166
179
 
@@ -198,40 +211,3 @@ async def set_user_groups(
198
211
  user_with_groups = await _get_single_user_with_groups(user, db)
199
212
 
200
213
  return user_with_groups
201
-
202
-
203
- @router_users.get(
204
- "/users/{user_id}/settings/", response_model=UserSettingsRead
205
- )
206
- async def get_user_settings(
207
- user_id: int,
208
- superuser: UserOAuth = Depends(current_active_superuser),
209
- db: AsyncSession = Depends(get_async_db),
210
- ) -> UserSettingsRead:
211
- user = await _user_or_404(user_id=user_id, db=db)
212
- verify_user_has_settings(user)
213
- user_settings = await db.get(UserSettings, user.user_settings_id)
214
- return user_settings
215
-
216
-
217
- @router_users.patch(
218
- "/users/{user_id}/settings/", response_model=UserSettingsRead
219
- )
220
- async def patch_user_settings(
221
- user_id: int,
222
- settings_update: UserSettingsUpdate,
223
- superuser: UserOAuth = Depends(current_active_superuser),
224
- db: AsyncSession = Depends(get_async_db),
225
- ) -> UserSettingsRead:
226
- user = await _user_or_404(user_id=user_id, db=db)
227
- verify_user_has_settings(user)
228
- user_settings = await db.get(UserSettings, user.user_settings_id)
229
-
230
- for k, v in settings_update.model_dump(exclude_unset=True).items():
231
- setattr(user_settings, k, v)
232
-
233
- db.add(user_settings)
234
- await db.commit()
235
- await db.refresh(user_settings)
236
-
237
- return user_settings
@@ -3,10 +3,11 @@ from fastapi import status
3
3
 
4
4
  from ....config import get_settings
5
5
  from ....syringe import Inject
6
+ from fractal_server.app.schemas.v2 import ResourceType
6
7
 
7
8
 
8
9
  def _backend_supports_shutdown(backend: str) -> bool:
9
- if backend in ["slurm", "slurm_ssh"]:
10
+ if backend in [ResourceType.SLURM_SUDO, ResourceType.SLURM_SSH]:
10
11
  return True
11
12
  else:
12
13
  return False
@@ -0,0 +1,62 @@
1
+ from fastapi import HTTPException
2
+ from fastapi import status
3
+ from pydantic import ValidationError
4
+
5
+ from fractal_server.app.db import AsyncSession
6
+ from fractal_server.app.models import Profile
7
+ from fractal_server.app.models import Resource
8
+ from fractal_server.app.models import UserOAuth
9
+ from fractal_server.app.schemas.v2.profile import cast_serialize_profile
10
+ from fractal_server.app.schemas.v2.resource import cast_serialize_resource
11
+ from fractal_server.logger import set_logger
12
+
13
+ logger = set_logger(__name__)
14
+
15
+
16
+ async def user_has_profile_or_422(*, user: UserOAuth) -> None:
17
+ if user.profile_id is None:
18
+ raise HTTPException(
19
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
20
+ detail=(
21
+ f"User {user.email} is not associated to a computational "
22
+ "profile. Please contact an admin."
23
+ ),
24
+ )
25
+
26
+
27
+ async def validate_user_profile(
28
+ *,
29
+ user: UserOAuth,
30
+ db: AsyncSession,
31
+ ) -> tuple[Resource, Profile]:
32
+ """
33
+ Validate profile and resource associated to a given user.
34
+
35
+ Note: this only returns non-db-bound objects.
36
+ """
37
+ await user_has_profile_or_422(user=user)
38
+ profile = await db.get(Profile, user.profile_id)
39
+ resource = await db.get(Resource, profile.resource_id)
40
+ try:
41
+ cast_serialize_resource(
42
+ resource.model_dump(exclude={"id", "timestamp_created"}),
43
+ )
44
+ cast_serialize_profile(
45
+ profile.model_dump(exclude={"resource_id", "id"}),
46
+ )
47
+ db.expunge(resource)
48
+ db.expunge(profile)
49
+
50
+ return resource, profile
51
+
52
+ except ValidationError as e:
53
+ error_msg = (
54
+ "User resource/profile are not valid for "
55
+ f"resource type '{resource.type}'. "
56
+ f"Original error: {str(e)}"
57
+ )
58
+ logger.warning(error_msg)
59
+ raise HTTPException(
60
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
61
+ detail=error_msg,
62
+ )
@@ -1,3 +1,2 @@
1
1
  from .user import * # noqa: F401, F403
2
2
  from .user_group import * # noqa: F401, F403
3
- from .user_settings import * # noqa: F401, F403
@@ -1,19 +1,18 @@
1
+ from typing import Annotated
2
+
1
3
  from fastapi_users import schemas
4
+ from pydantic import AfterValidator
2
5
  from pydantic import BaseModel
3
6
  from pydantic import ConfigDict
4
7
  from pydantic import EmailStr
5
8
  from pydantic import Field
6
9
 
10
+ from fractal_server.string_tools import validate_cmd
11
+ from fractal_server.types import AbsolutePathStr
12
+ from fractal_server.types import ListUniqueNonEmptyString
7
13
  from fractal_server.types import ListUniqueNonNegativeInt
8
14
  from fractal_server.types import NonEmptyStr
9
15
 
10
- __all__ = (
11
- "UserRead",
12
- "UserUpdate",
13
- "UserUpdateGroups",
14
- "UserCreate",
15
- )
16
-
17
16
 
18
17
  class OAuthAccountRead(BaseModel):
19
18
  """
@@ -36,12 +35,21 @@ class UserRead(schemas.BaseUser[int]):
36
35
  Schema for `User` read from database.
37
36
 
38
37
  Attributes:
39
- username:
38
+ group_ids_names:
39
+ oauth_accounts:
40
+ profile_id:
40
41
  """
41
42
 
42
- username: str | None = None
43
43
  group_ids_names: list[tuple[int, str]] | None = None
44
44
  oauth_accounts: list[OAuthAccountRead]
45
+ profile_id: int | None = None
46
+ project_dir: str
47
+ slurm_accounts: list[str]
48
+
49
+
50
+ def _validate_cmd(value: str) -> str:
51
+ validate_cmd(value)
52
+ return value
45
53
 
46
54
 
47
55
  class UserUpdate(schemas.BaseUserUpdate):
@@ -49,16 +57,27 @@ class UserUpdate(schemas.BaseUserUpdate):
49
57
  Schema for `User` update.
50
58
 
51
59
  Attributes:
52
- username:
60
+ password:
61
+ email:
62
+ is_active:
63
+ is_superuser:
64
+ is_verified:
65
+ profile_id:
66
+ project_dir:
67
+ slurm_accounts:
53
68
  """
54
69
 
55
70
  model_config = ConfigDict(extra="forbid")
56
- username: NonEmptyStr = None
57
71
  password: NonEmptyStr = None
58
72
  email: EmailStr = None
59
73
  is_active: bool = None
60
74
  is_superuser: bool = None
61
75
  is_verified: bool = None
76
+ profile_id: int | None = None
77
+ project_dir: Annotated[
78
+ AbsolutePathStr, AfterValidator(_validate_cmd)
79
+ ] = None
80
+ slurm_accounts: ListUniqueNonEmptyString = None
62
81
 
63
82
 
64
83
  class UserUpdateStrict(BaseModel):
@@ -66,9 +85,11 @@ class UserUpdateStrict(BaseModel):
66
85
  Schema for `User` self-editing.
67
86
 
68
87
  Attributes:
88
+ slurm_accounts:
69
89
  """
70
90
 
71
91
  model_config = ConfigDict(extra="forbid")
92
+ slurm_accounts: ListUniqueNonEmptyString = None
72
93
 
73
94
 
74
95
  class UserCreate(schemas.BaseUserCreate):
@@ -76,10 +97,12 @@ class UserCreate(schemas.BaseUserCreate):
76
97
  Schema for `User` creation.
77
98
 
78
99
  Attributes:
79
- username:
100
+ profile_id:
80
101
  """
81
102
 
82
- username: NonEmptyStr = None
103
+ profile_id: int | None = None
104
+ project_dir: Annotated[AbsolutePathStr, AfterValidator(_validate_cmd)]
105
+ slurm_accounts: list[str] = Field(default_factory=list)
83
106
 
84
107
 
85
108
  class UserUpdateGroups(BaseModel):
@@ -91,3 +114,10 @@ class UserUpdateGroups(BaseModel):
91
114
  model_config = ConfigDict(extra="forbid")
92
115
 
93
116
  group_ids: ListUniqueNonNegativeInt = Field(min_length=1)
117
+
118
+
119
+ class UserProfileInfo(BaseModel):
120
+ has_profile: bool
121
+ resource_name: str | None = None
122
+ profile_name: str | None = None
123
+ username: str | None = None
@@ -7,6 +7,7 @@ from pydantic import field_serializer
7
7
  from pydantic.types import AwareDatetime
8
8
 
9
9
  from fractal_server.types import ListUniqueAbsolutePathStr
10
+ from fractal_server.types import NonEmptyStr
10
11
 
11
12
  __all__ = (
12
13
  "UserGroupRead",
@@ -50,7 +51,7 @@ class UserGroupCreate(BaseModel):
50
51
 
51
52
  model_config = ConfigDict(extra="forbid")
52
53
 
53
- name: str
54
+ name: NonEmptyStr
54
55
  viewer_paths: ListUniqueAbsolutePathStr = Field(default_factory=list)
55
56
 
56
57
 
@@ -22,9 +22,20 @@ from .job import JobStatusTypeV2 # noqa F401
22
22
  from .job import JobUpdateV2 # noqa F401
23
23
  from .manifest import ManifestV2 # noqa F401
24
24
  from .manifest import TaskManifestV2 # noqa F401
25
+ from .profile import ProfileCreate # noqa F401
26
+ from .profile import ProfileRead # noqa F401
27
+ from .profile import ValidProfileLocal # noqa F401
28
+ from .profile import ValidProfileSlurmSSH # noqa F401
29
+ from .profile import ValidProfileSlurmSudo # noqa F401
25
30
  from .project import ProjectCreateV2 # noqa F401
26
31
  from .project import ProjectReadV2 # noqa F401
27
32
  from .project import ProjectUpdateV2 # noqa F401
33
+ from .resource import ResourceCreate # noqa F401
34
+ from .resource import ResourceRead # noqa F401
35
+ from .resource import ResourceType # noqa F401
36
+ from .resource import ValidResourceLocal # noqa F401
37
+ from .resource import ValidResourceSlurmSSH # noqa F401
38
+ from .resource import ValidResourceSlurmSudo # noqa F401
28
39
  from .status_legacy import WorkflowTaskStatusTypeV2 # noqa F401
29
40
  from .task import TaskCreateV2 # noqa F401
30
41
  from .task import TaskExportV2 # noqa F401
@@ -41,6 +52,7 @@ from .task_group import TaskGroupActivityStatusV2 # noqa F401
41
52
  from .task_group import TaskGroupActivityV2Read # noqa F401
42
53
  from .task_group import TaskGroupCreateV2 # noqa F401
43
54
  from .task_group import TaskGroupCreateV2Strict # noqa F401
55
+ from .task_group import TaskGroupReadSuperuser # noqa F401
44
56
  from .task_group import TaskGroupReadV2 # noqa F401
45
57
  from .task_group import TaskGroupUpdateV2 # noqa F401
46
58
  from .task_group import TaskGroupV2OriginEnum # noqa F401
@@ -0,0 +1,78 @@
1
+ from typing import Annotated
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel
5
+ from pydantic import Discriminator
6
+ from pydantic import Tag
7
+ from pydantic import validate_call
8
+
9
+ from .resource import ResourceType
10
+ from fractal_server.types import AbsolutePathStr
11
+ from fractal_server.types import NonEmptyStr
12
+
13
+
14
+ class ValidProfileLocal(BaseModel):
15
+ name: NonEmptyStr
16
+ resource_type: ResourceType
17
+ username: None = None
18
+ ssh_key_path: None = None
19
+ jobs_remote_dir: None = None
20
+ tasks_remote_dir: None = None
21
+
22
+
23
+ class ValidProfileSlurmSudo(BaseModel):
24
+ name: NonEmptyStr
25
+ resource_type: ResourceType
26
+ username: NonEmptyStr
27
+ ssh_key_path: None = None
28
+ jobs_remote_dir: None = None
29
+ tasks_remote_dir: None = None
30
+
31
+
32
+ class ValidProfileSlurmSSH(BaseModel):
33
+ name: NonEmptyStr
34
+ resource_type: ResourceType
35
+ username: NonEmptyStr
36
+ ssh_key_path: AbsolutePathStr
37
+ jobs_remote_dir: AbsolutePathStr
38
+ tasks_remote_dir: AbsolutePathStr
39
+
40
+
41
+ def get_discriminator_value(v: Any) -> str:
42
+ # See https://github.com/fastapi/fastapi/discussions/12941
43
+ if isinstance(v, dict):
44
+ return v.get("resource_type", None)
45
+ return getattr(v, "resource_type", None)
46
+
47
+
48
+ ProfileCreate = Annotated[
49
+ Annotated[ValidProfileLocal, Tag(ResourceType.LOCAL)]
50
+ | Annotated[ValidProfileSlurmSudo, Tag(ResourceType.SLURM_SUDO)]
51
+ | Annotated[ValidProfileSlurmSSH, Tag(ResourceType.SLURM_SSH)],
52
+ Discriminator(get_discriminator_value),
53
+ ]
54
+
55
+
56
+ class ProfileRead(BaseModel):
57
+ id: int
58
+ name: str
59
+ resource_id: int
60
+ resource_type: str
61
+ username: str | None = None
62
+ ssh_key_path: str | None = None
63
+ jobs_remote_dir: str | None = None
64
+ tasks_remote_dir: str | None = None
65
+
66
+
67
+ @validate_call
68
+ def cast_serialize_profile(_data: ProfileCreate) -> dict[str, Any]:
69
+ """
70
+ Cast/serialize round-trip for `Profile` data.
71
+
72
+ We use `@validate_call` because `ProfileeCreate` is a `Union` type and it
73
+ cannot be instantiated directly.
74
+
75
+ Return:
76
+ Serialized version of a valid profile object.
77
+ """
78
+ return _data.model_dump()