fractal-server 2.16.6__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 (142) 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/profile.py +16 -0
  8. fractal_server/app/models/v2/project.py +5 -0
  9. fractal_server/app/models/v2/resource.py +130 -0
  10. fractal_server/app/models/v2/task_group.py +4 -0
  11. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  12. fractal_server/app/routes/admin/v2/_aux_functions.py +55 -0
  13. fractal_server/app/routes/admin/v2/accounting.py +3 -3
  14. fractal_server/app/routes/admin/v2/impersonate.py +2 -2
  15. fractal_server/app/routes/admin/v2/job.py +51 -15
  16. fractal_server/app/routes/admin/v2/profile.py +100 -0
  17. fractal_server/app/routes/admin/v2/project.py +2 -2
  18. fractal_server/app/routes/admin/v2/resource.py +222 -0
  19. fractal_server/app/routes/admin/v2/task.py +59 -32
  20. fractal_server/app/routes/admin/v2/task_group.py +17 -12
  21. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +52 -86
  22. fractal_server/app/routes/api/__init__.py +45 -8
  23. fractal_server/app/routes/api/v2/_aux_functions.py +17 -1
  24. fractal_server/app/routes/api/v2/_aux_functions_history.py +2 -2
  25. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
  26. fractal_server/app/routes/api/v2/_aux_functions_tasks.py +55 -19
  27. fractal_server/app/routes/api/v2/_aux_task_group_disambiguation.py +21 -17
  28. fractal_server/app/routes/api/v2/dataset.py +10 -19
  29. fractal_server/app/routes/api/v2/history.py +8 -8
  30. fractal_server/app/routes/api/v2/images.py +5 -5
  31. fractal_server/app/routes/api/v2/job.py +8 -8
  32. fractal_server/app/routes/api/v2/pre_submission_checks.py +3 -3
  33. fractal_server/app/routes/api/v2/project.py +15 -7
  34. fractal_server/app/routes/api/v2/status_legacy.py +2 -2
  35. fractal_server/app/routes/api/v2/submit.py +49 -42
  36. fractal_server/app/routes/api/v2/task.py +26 -8
  37. fractal_server/app/routes/api/v2/task_collection.py +39 -50
  38. fractal_server/app/routes/api/v2/task_collection_custom.py +10 -6
  39. fractal_server/app/routes/api/v2/task_collection_pixi.py +34 -42
  40. fractal_server/app/routes/api/v2/task_group.py +19 -9
  41. fractal_server/app/routes/api/v2/task_group_lifecycle.py +43 -86
  42. fractal_server/app/routes/api/v2/task_version_update.py +3 -3
  43. fractal_server/app/routes/api/v2/workflow.py +9 -9
  44. fractal_server/app/routes/api/v2/workflow_import.py +25 -13
  45. fractal_server/app/routes/api/v2/workflowtask.py +5 -5
  46. fractal_server/app/routes/auth/__init__.py +34 -5
  47. fractal_server/app/routes/auth/_aux_auth.py +39 -20
  48. fractal_server/app/routes/auth/current_user.py +56 -67
  49. fractal_server/app/routes/auth/group.py +29 -46
  50. fractal_server/app/routes/auth/oauth.py +55 -38
  51. fractal_server/app/routes/auth/register.py +2 -2
  52. fractal_server/app/routes/auth/router.py +4 -2
  53. fractal_server/app/routes/auth/users.py +29 -53
  54. fractal_server/app/routes/aux/_runner.py +2 -1
  55. fractal_server/app/routes/aux/validate_user_profile.py +62 -0
  56. fractal_server/app/schemas/__init__.py +0 -1
  57. fractal_server/app/schemas/user.py +43 -13
  58. fractal_server/app/schemas/user_group.py +2 -1
  59. fractal_server/app/schemas/v2/__init__.py +12 -0
  60. fractal_server/app/schemas/v2/profile.py +78 -0
  61. fractal_server/app/schemas/v2/resource.py +137 -0
  62. fractal_server/app/schemas/v2/task_collection.py +11 -3
  63. fractal_server/app/schemas/v2/task_group.py +5 -0
  64. fractal_server/app/security/__init__.py +174 -75
  65. fractal_server/app/security/signup_email.py +52 -34
  66. fractal_server/config/__init__.py +27 -0
  67. fractal_server/config/_data.py +68 -0
  68. fractal_server/config/_database.py +59 -0
  69. fractal_server/config/_email.py +133 -0
  70. fractal_server/config/_main.py +78 -0
  71. fractal_server/config/_oauth.py +69 -0
  72. fractal_server/config/_settings_config.py +7 -0
  73. fractal_server/data_migrations/2_17_0.py +339 -0
  74. fractal_server/images/tools.py +3 -3
  75. fractal_server/logger.py +3 -3
  76. fractal_server/main.py +17 -23
  77. fractal_server/migrations/naming_convention.py +1 -1
  78. fractal_server/migrations/versions/83bc2ad3ffcc_2_17_0.py +195 -0
  79. fractal_server/runner/config/__init__.py +2 -0
  80. fractal_server/runner/config/_local.py +21 -0
  81. fractal_server/runner/config/_slurm.py +129 -0
  82. fractal_server/runner/config/slurm_mem_to_MB.py +63 -0
  83. fractal_server/runner/exceptions.py +4 -0
  84. fractal_server/runner/executors/base_runner.py +17 -7
  85. fractal_server/runner/executors/local/get_local_config.py +21 -86
  86. fractal_server/runner/executors/local/runner.py +48 -5
  87. fractal_server/runner/executors/slurm_common/_batching.py +2 -2
  88. fractal_server/runner/executors/slurm_common/base_slurm_runner.py +60 -26
  89. fractal_server/runner/executors/slurm_common/get_slurm_config.py +39 -55
  90. fractal_server/runner/executors/slurm_common/remote.py +1 -1
  91. fractal_server/runner/executors/slurm_common/slurm_config.py +214 -0
  92. fractal_server/runner/executors/slurm_common/slurm_job_task_models.py +1 -1
  93. fractal_server/runner/executors/slurm_ssh/runner.py +12 -14
  94. fractal_server/runner/executors/slurm_sudo/_subprocess_run_as_user.py +2 -2
  95. fractal_server/runner/executors/slurm_sudo/runner.py +12 -12
  96. fractal_server/runner/v2/_local.py +36 -21
  97. fractal_server/runner/v2/_slurm_ssh.py +41 -4
  98. fractal_server/runner/v2/_slurm_sudo.py +42 -12
  99. fractal_server/runner/v2/db_tools.py +1 -1
  100. fractal_server/runner/v2/runner.py +3 -11
  101. fractal_server/runner/v2/runner_functions.py +42 -28
  102. fractal_server/runner/v2/submit_workflow.py +88 -109
  103. fractal_server/runner/versions.py +8 -3
  104. fractal_server/ssh/_fabric.py +6 -6
  105. fractal_server/tasks/config/__init__.py +3 -0
  106. fractal_server/tasks/config/_pixi.py +127 -0
  107. fractal_server/tasks/config/_python.py +51 -0
  108. fractal_server/tasks/v2/local/_utils.py +7 -7
  109. fractal_server/tasks/v2/local/collect.py +13 -5
  110. fractal_server/tasks/v2/local/collect_pixi.py +26 -10
  111. fractal_server/tasks/v2/local/deactivate.py +7 -1
  112. fractal_server/tasks/v2/local/deactivate_pixi.py +5 -1
  113. fractal_server/tasks/v2/local/delete.py +5 -1
  114. fractal_server/tasks/v2/local/reactivate.py +13 -5
  115. fractal_server/tasks/v2/local/reactivate_pixi.py +27 -9
  116. fractal_server/tasks/v2/ssh/_pixi_slurm_ssh.py +11 -10
  117. fractal_server/tasks/v2/ssh/_utils.py +6 -7
  118. fractal_server/tasks/v2/ssh/collect.py +19 -12
  119. fractal_server/tasks/v2/ssh/collect_pixi.py +34 -16
  120. fractal_server/tasks/v2/ssh/deactivate.py +12 -8
  121. fractal_server/tasks/v2/ssh/deactivate_pixi.py +14 -10
  122. fractal_server/tasks/v2/ssh/delete.py +12 -9
  123. fractal_server/tasks/v2/ssh/reactivate.py +18 -12
  124. fractal_server/tasks/v2/ssh/reactivate_pixi.py +36 -17
  125. fractal_server/tasks/v2/templates/4_pip_show.sh +4 -6
  126. fractal_server/tasks/v2/utils_database.py +2 -2
  127. fractal_server/tasks/v2/utils_pixi.py +3 -0
  128. fractal_server/tasks/v2/utils_python_interpreter.py +8 -16
  129. fractal_server/tasks/v2/utils_templates.py +7 -10
  130. fractal_server/utils.py +1 -1
  131. {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/METADATA +4 -6
  132. {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/RECORD +136 -117
  133. fractal_server/app/routes/aux/validate_user_settings.py +0 -73
  134. fractal_server/app/schemas/user_settings.py +0 -67
  135. fractal_server/app/user_settings.py +0 -42
  136. fractal_server/config.py +0 -906
  137. fractal_server/data_migrations/2_14_10.py +0 -48
  138. fractal_server/runner/executors/slurm_common/_slurm_config.py +0 -471
  139. /fractal_server/{runner → app}/shutdown.py +0 -0
  140. {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/WHEEL +0 -0
  141. {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/entry_points.txt +0 -0
  142. {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,137 @@
1
+ from enum import StrEnum
2
+ from typing import Annotated
3
+ from typing import Any
4
+ from typing import Literal
5
+ from typing import Self
6
+
7
+ from pydantic import AfterValidator
8
+ from pydantic import BaseModel
9
+ from pydantic import Discriminator
10
+ from pydantic import model_validator
11
+ from pydantic import Tag
12
+ from pydantic import validate_call
13
+ from pydantic.types import AwareDatetime
14
+
15
+ from fractal_server.runner.config import JobRunnerConfigLocal
16
+ from fractal_server.runner.config import JobRunnerConfigSLURM
17
+ from fractal_server.tasks.config import TasksPixiSettings
18
+ from fractal_server.tasks.config import TasksPythonSettings
19
+ from fractal_server.types import AbsolutePathStr
20
+ from fractal_server.types import NonEmptyStr
21
+
22
+
23
+ class ResourceType(StrEnum):
24
+ SLURM_SUDO = "slurm_sudo"
25
+ SLURM_SSH = "slurm_ssh"
26
+ LOCAL = "local"
27
+
28
+
29
+ def cast_serialize_pixi_settings(
30
+ v: dict[NonEmptyStr, Any],
31
+ ) -> dict[NonEmptyStr, Any]:
32
+ """
33
+ Validate current value, and enrich it with default values.
34
+ """
35
+ if v != {}:
36
+ v = TasksPixiSettings(**v).model_dump()
37
+ return v
38
+
39
+
40
+ class _ValidResourceBase(BaseModel):
41
+ type: ResourceType
42
+ name: NonEmptyStr
43
+
44
+ # Tasks
45
+ tasks_python_config: TasksPythonSettings
46
+ tasks_pixi_config: Annotated[
47
+ dict[NonEmptyStr, Any],
48
+ AfterValidator(cast_serialize_pixi_settings),
49
+ ]
50
+ tasks_local_dir: AbsolutePathStr
51
+
52
+ # Jobs
53
+ jobs_local_dir: AbsolutePathStr
54
+ jobs_runner_config: dict[NonEmptyStr, Any]
55
+ jobs_poll_interval: int = 5
56
+
57
+ @model_validator(mode="after")
58
+ def _pixi_slurm_config(self) -> Self:
59
+ if (
60
+ self.tasks_pixi_config != {}
61
+ and self.type == ResourceType.SLURM_SSH
62
+ and self.tasks_pixi_config["SLURM_CONFIG"] is None
63
+ ):
64
+ raise ValueError(
65
+ "`tasks_pixi_config` must include `SLURM_CONFIG`."
66
+ )
67
+ return self
68
+
69
+
70
+ class ValidResourceLocal(_ValidResourceBase):
71
+ type: Literal[ResourceType.LOCAL]
72
+ jobs_runner_config: JobRunnerConfigLocal
73
+ jobs_slurm_python_worker: None = None
74
+ host: None = None
75
+
76
+
77
+ class ValidResourceSlurmSudo(_ValidResourceBase):
78
+ type: Literal[ResourceType.SLURM_SUDO]
79
+ jobs_slurm_python_worker: AbsolutePathStr
80
+ jobs_runner_config: JobRunnerConfigSLURM
81
+ host: None = None
82
+
83
+
84
+ class ValidResourceSlurmSSH(_ValidResourceBase):
85
+ type: Literal[ResourceType.SLURM_SSH]
86
+ host: NonEmptyStr
87
+ jobs_slurm_python_worker: AbsolutePathStr
88
+ jobs_runner_config: JobRunnerConfigSLURM
89
+
90
+
91
+ def get_discriminator_value(v: Any) -> str:
92
+ # See https://github.com/fastapi/fastapi/discussions/12941
93
+ if isinstance(v, dict):
94
+ return v.get("type", None)
95
+ return getattr(v, "type", None)
96
+
97
+
98
+ ResourceCreate = Annotated[
99
+ Annotated[ValidResourceLocal, Tag(ResourceType.LOCAL)]
100
+ | Annotated[ValidResourceSlurmSudo, Tag(ResourceType.SLURM_SUDO)]
101
+ | Annotated[ValidResourceSlurmSSH, Tag(ResourceType.SLURM_SSH)],
102
+ Discriminator(get_discriminator_value),
103
+ ]
104
+
105
+
106
+ class ResourceRead(BaseModel):
107
+ id: int
108
+
109
+ type: str
110
+
111
+ name: str
112
+ timestamp_created: AwareDatetime
113
+
114
+ host: str | None
115
+
116
+ jobs_local_dir: str
117
+ jobs_runner_config: dict[str, Any]
118
+ jobs_slurm_python_worker: str | None
119
+ jobs_poll_interval: int
120
+
121
+ tasks_local_dir: str
122
+ tasks_python_config: dict[str, Any]
123
+ tasks_pixi_config: dict[str, Any]
124
+
125
+
126
+ @validate_call
127
+ def cast_serialize_resource(_data: ResourceCreate) -> dict[str, Any]:
128
+ """
129
+ Cast/serialize round-trip for `Resource` data.
130
+
131
+ We use `@validate_call` because `ResourceCreate` is a `Union` type and it
132
+ cannot be instantiated directly.
133
+
134
+ Return:
135
+ Serialized version of a valid resource object.
136
+ """
137
+ return _data.model_dump()
@@ -51,9 +51,17 @@ class TaskCollectPipV2(BaseModel):
51
51
  package: NonEmptyStr | None = None
52
52
  package_version: NonEmptyStr | None = None
53
53
  package_extras: NonEmptyStr | None = None
54
- python_version: Literal[
55
- "3.9", "3.10", "3.11", "3.12", "3.13"
56
- ] | None = None
54
+ python_version: (
55
+ Literal[
56
+ "3.9",
57
+ "3.10",
58
+ "3.11",
59
+ "3.12",
60
+ "3.13",
61
+ "3.14",
62
+ ]
63
+ | None
64
+ ) = None
57
65
  pinned_package_versions_pre: DictStrStr | None = None
58
66
  pinned_package_versions_post: DictStrStr | None = None
59
67
 
@@ -37,6 +37,7 @@ class TaskGroupActivityActionV2(StrEnum):
37
37
  class TaskGroupCreateV2(BaseModel):
38
38
  model_config = ConfigDict(extra="forbid")
39
39
  user_id: int
40
+ resource_id: int
40
41
  user_group_id: int | None = None
41
42
  active: bool = True
42
43
  origin: TaskGroupV2OriginEnum
@@ -95,6 +96,10 @@ class TaskGroupReadV2(BaseModel):
95
96
  return v.isoformat()
96
97
 
97
98
 
99
+ class TaskGroupReadSuperuser(TaskGroupReadV2):
100
+ resource_id: int
101
+
102
+
98
103
  class TaskGroupUpdateV2(BaseModel):
99
104
  model_config = ConfigDict(extra="forbid")
100
105
  user_group_id: int | None = None
@@ -1,14 +1,3 @@
1
- # Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
2
- # University of Zurich
3
- #
4
- # Original authors:
5
- # Jacopo Nespolo <jacopo.nespolo@exact-lab.it>
6
- # Tommaso Comparin <tommaso.comparin@exact-lab.it>
7
- #
8
- # This file is part of Fractal and was originally developed by eXact lab S.r.l.
9
- # <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
10
- # Institute for Biomedical Research and Pelkmans Lab from the University of
11
- # Zurich.
12
1
  """
13
2
  Auth subsystem
14
3
 
@@ -30,6 +19,7 @@ import contextlib
30
19
  from collections.abc import AsyncGenerator
31
20
  from typing import Any
32
21
  from typing import Generic
22
+ from typing import Self
33
23
 
34
24
  from fastapi import Depends
35
25
  from fastapi import Request
@@ -55,17 +45,18 @@ from fractal_server.app.models import LinkUserGroup
55
45
  from fractal_server.app.models import OAuthAccount
56
46
  from fractal_server.app.models import UserGroup
57
47
  from fractal_server.app.models import UserOAuth
58
- from fractal_server.app.models import UserSettings
59
48
  from fractal_server.app.schemas.user import UserCreate
60
- from fractal_server.app.security.signup_email import mail_new_oauth_signup
49
+ from fractal_server.app.security.signup_email import (
50
+ send_fractal_email_or_log_failure,
51
+ )
52
+ from fractal_server.config import get_email_settings
61
53
  from fractal_server.config import get_settings
54
+ from fractal_server.logger import close_logger
62
55
  from fractal_server.logger import set_logger
63
56
  from fractal_server.syringe import Inject
64
57
 
65
58
  logger = set_logger(__name__)
66
59
 
67
- FRACTAL_DEFAULT_GROUP_NAME = "All"
68
-
69
60
 
70
61
  class SQLModelUserDatabaseAsync(Generic[UP, ID], BaseUserDatabase[UP, ID]):
71
62
  """
@@ -209,67 +200,166 @@ class UserManager(IntegerIDMixin, BaseUserManager[UserOAuth, int]):
209
200
  f"The password is too long (maximum length: {min_length})."
210
201
  )
211
202
 
203
+ async def oauth_callback(
204
+ self: Self,
205
+ oauth_name: str,
206
+ access_token: str,
207
+ account_id: str,
208
+ account_email: str,
209
+ expires_at: int | None = None,
210
+ refresh_token: str | None = None,
211
+ request: Request | None = None,
212
+ *,
213
+ associate_by_email: bool = False,
214
+ is_verified_by_default: bool = False,
215
+ ) -> UserOAuth:
216
+ """
217
+ Handle the callback after a successful OAuth authentication.
218
+
219
+ This method extends the corresponding `BaseUserManager` method of
220
+ > fastapi-users v14.0.1, Copyright (c) 2019 François Voron, MIT License
221
+
222
+ If the user already exists with this OAuth account, the token is
223
+ updated.
224
+
225
+ If a user with the same e-mail already exists and `associate_by_email`
226
+ is True, the OAuth account is associated to this user.
227
+ Otherwise, the `UserNotExists` exception is raised.
228
+
229
+ If the user does not exist, send an email to the Fractal admins (if
230
+ configured) and respond with a 400 error status. NOTE: This is the
231
+ function branch where the `fractal-server` implementation deviates
232
+ from the original `fastapi-users` one.
233
+
234
+ :param oauth_name: Name of the OAuth client.
235
+ :param access_token: Valid access token for the service provider.
236
+ :param account_id: models.ID of the user on the service provider.
237
+ :param account_email: E-mail of the user on the service provider.
238
+ :param expires_at: Optional timestamp at which the access token
239
+ expires.
240
+ :param refresh_token: Optional refresh token to get a
241
+ fresh access token from the service provider.
242
+ :param request: Optional FastAPI request that
243
+ triggered the operation, defaults to None
244
+ :param associate_by_email: If True, any existing user with the same
245
+ e-mail address will be associated to this user. Defaults to False.
246
+ :param is_verified_by_default: If True, the `is_verified` flag will be
247
+ set to `True` on newly created user. Make sure the OAuth Provider you
248
+ are using does verify the email address before enabling this flag.
249
+ Defaults to False.
250
+ :return: A user.
251
+ """
252
+ from fastapi import HTTPException
253
+ from fastapi import status
254
+ from fastapi_users import exceptions
255
+
256
+ oauth_account_dict = {
257
+ "oauth_name": oauth_name,
258
+ "access_token": access_token,
259
+ "account_id": account_id,
260
+ "account_email": account_email,
261
+ "expires_at": expires_at,
262
+ "refresh_token": refresh_token,
263
+ }
264
+
265
+ try:
266
+ user = await self.get_by_oauth_account(oauth_name, account_id)
267
+ except exceptions.UserNotExists:
268
+ try:
269
+ # Associate account
270
+ user = await self.get_by_email(account_email)
271
+ if not associate_by_email:
272
+ raise exceptions.UserAlreadyExists()
273
+ user = await self.user_db.add_oauth_account(
274
+ user, oauth_account_dict
275
+ )
276
+ except exceptions.UserNotExists:
277
+ # (0) Log
278
+ logger.warning(
279
+ f"Self-registration attempt by {account_email}."
280
+ )
281
+
282
+ # (1) Prepare user-facing error message
283
+ error_msg = (
284
+ "Thank you for registering for the Fractal service. "
285
+ "Administrators have been informed to configure your "
286
+ "account and will get back to you."
287
+ )
288
+ settings = Inject(get_settings)
289
+ if settings.FRACTAL_HELP_URL is not None:
290
+ error_msg = (
291
+ f"{error_msg}\n"
292
+ "You can find more information about the onboarding "
293
+ f"process at {settings.FRACTAL_HELP_URL}."
294
+ )
295
+
296
+ # (2) Send email to admins
297
+ email_settings = Inject(get_email_settings)
298
+ send_fractal_email_or_log_failure(
299
+ subject="New OAuth self-registration",
300
+ msg=(
301
+ f"User '{account_email}' tried to "
302
+ "self-register through OAuth.\n"
303
+ "Please create the Fractal account manually.\n"
304
+ "Here is the error message displayed to the "
305
+ f"user:\n{error_msg}"
306
+ ),
307
+ email_settings=email_settings.public,
308
+ )
309
+
310
+ # (3) Raise
311
+ raise HTTPException(
312
+ status_code=status.HTTP_400_BAD_REQUEST,
313
+ detail=error_msg,
314
+ )
315
+ else:
316
+ # Update oauth
317
+ for existing_oauth_account in user.oauth_accounts:
318
+ if (
319
+ existing_oauth_account.account_id == account_id
320
+ and existing_oauth_account.oauth_name == oauth_name
321
+ ):
322
+ user = await self.user_db.update_oauth_account(
323
+ user, existing_oauth_account, oauth_account_dict
324
+ )
325
+
326
+ return user
327
+
212
328
  async def on_after_register(
213
329
  self, user: UserOAuth, request: Request | None = None
214
330
  ):
331
+ settings = Inject(get_settings)
215
332
  logger.info(
216
333
  f"New-user registration completed ({user.id=}, {user.email=})."
217
334
  )
218
335
  async for db in get_async_db():
219
- # Find default group
220
- stm = select(UserGroup).where(
221
- UserGroup.name == FRACTAL_DEFAULT_GROUP_NAME
336
+ # Note: if `FRACTAL_DEFAULT_GROUP_NAME=None`, this query will
337
+ # result into `None`
338
+ settings = Inject(get_settings)
339
+ stm = select(UserGroup.id).where(
340
+ UserGroup.name == settings.FRACTAL_DEFAULT_GROUP_NAME
222
341
  )
223
342
  res = await db.execute(stm)
224
- default_group = res.scalar_one_or_none()
225
- if default_group is None:
226
- logger.warning(
227
- f"No group found with name {FRACTAL_DEFAULT_GROUP_NAME}"
228
- )
229
- else:
343
+ default_group_id_or_none = res.scalars().one_or_none()
344
+ if default_group_id_or_none is not None:
230
345
  link = LinkUserGroup(
231
- user_id=user.id, group_id=default_group.id
346
+ user_id=user.id, group_id=default_group_id_or_none
232
347
  )
233
348
  db.add(link)
234
349
  await db.commit()
235
350
  logger.info(
236
- f"Added {user.email} user to group {default_group.id=}."
351
+ f"Added {user.email} user to group "
352
+ f"{default_group_id_or_none=}."
237
353
  )
238
-
239
- this_user = await db.get(UserOAuth, user.id)
240
-
241
- this_user.settings = UserSettings()
242
- await db.merge(this_user)
243
- await db.commit()
244
- await db.refresh(this_user)
245
- logger.info(
246
- f"Associated empty settings (id={this_user.user_settings_id}) "
247
- f"to '{this_user.email}'."
248
- )
249
-
250
- # Send mail section
251
- settings = Inject(get_settings)
252
-
253
- if (
254
- this_user.oauth_accounts
255
- and settings.email_settings is not None
256
- ):
257
- try:
258
- logger.info(
259
- "START sending email about new signup to "
260
- f"{settings.email_settings.recipients}."
261
- )
262
- mail_new_oauth_signup(
263
- msg=f"New user registered: '{this_user.email}'.",
264
- email_settings=settings.email_settings,
265
- )
266
- logger.info("END sending email about new signup.")
267
- except Exception as e:
268
- logger.error(
269
- "ERROR sending notification email after oauth "
270
- f"registration of {this_user.email}. "
271
- f"Original error: '{e}'."
272
- )
354
+ elif settings.FRACTAL_DEFAULT_GROUP_NAME is not None:
355
+ logger.error(
356
+ "No group found with name "
357
+ f"{settings.FRACTAL_DEFAULT_GROUP_NAME}"
358
+ )
359
+ # NOTE: the `else` of this branch would simply be a `pass`. The
360
+ # "All" group was not found, but this is not worth a WARNING
361
+ # because `FRACTAL_DEFAULT_GROUP_NAME` is set to `None` in the
362
+ # settings.
273
363
 
274
364
 
275
365
  async def get_user_manager(
@@ -286,9 +376,10 @@ get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
286
376
  async def _create_first_user(
287
377
  email: str,
288
378
  password: str,
379
+ project_dir: str,
380
+ profile_id: int | None = None,
289
381
  is_superuser: bool = False,
290
382
  is_verified: bool = False,
291
- username: str | None = None,
292
383
  ) -> None:
293
384
  """
294
385
  Private method to create the first fractal-server user
@@ -306,12 +397,11 @@ async def _create_first_user(
306
397
  See [fastapi_users docs](https://fastapi-users.github.io/fastapi-users/
307
398
  12.1/cookbook/create-user-programmatically)
308
399
 
309
- Arguments:
400
+ Args:
310
401
  email: New user's email
311
402
  password: New user's password
312
403
  is_superuser: `True` if the new user is a superuser
313
404
  is_verified: `True` if the new user is verified
314
- username:
315
405
  """
316
406
  function_logger = set_logger("fractal_server.create_first_user")
317
407
  function_logger.info(f"START _create_first_user, with email '{email}'")
@@ -336,11 +426,11 @@ async def _create_first_user(
336
426
  kwargs = dict(
337
427
  email=email,
338
428
  password=password,
429
+ project_dir=project_dir,
430
+ profile_id=profile_id,
339
431
  is_superuser=is_superuser,
340
432
  is_verified=is_verified,
341
433
  )
342
- if username is not None:
343
- kwargs["username"] = username
344
434
  user = await user_manager.create(UserCreate(**kwargs))
345
435
  function_logger.info(f"User '{user.email}' created")
346
436
  except UserAlreadyExists:
@@ -352,34 +442,43 @@ async def _create_first_user(
352
442
  raise e
353
443
  finally:
354
444
  function_logger.info(f"END _create_first_user, with email '{email}'")
445
+ close_logger(function_logger)
355
446
 
356
447
 
357
448
  def _create_first_group():
358
449
  """
359
- Create a `UserGroup` with `name=FRACTAL_DEFAULT_GROUP_NAME`, if missing.
450
+ Create a `UserGroup` named `FRACTAL_DEFAULT_GROUP_NAME`, if this variable
451
+ is set and if such a group does not already exist.
360
452
  """
453
+ settings = Inject(get_settings)
361
454
  function_logger = set_logger("fractal_server.create_first_group")
362
455
 
456
+ if settings.FRACTAL_DEFAULT_GROUP_NAME is None:
457
+ function_logger.info(
458
+ f"SKIP because '{settings.FRACTAL_DEFAULT_GROUP_NAME=}'"
459
+ )
460
+ return
461
+
363
462
  function_logger.info(
364
- f"START _create_first_group, with name '{FRACTAL_DEFAULT_GROUP_NAME}'"
463
+ f"START, name '{settings.FRACTAL_DEFAULT_GROUP_NAME}'"
365
464
  )
366
465
  with next(get_sync_db()) as db:
367
466
  group_all = db.execute(
368
467
  select(UserGroup).where(
369
- UserGroup.name == FRACTAL_DEFAULT_GROUP_NAME
468
+ UserGroup.name == settings.FRACTAL_DEFAULT_GROUP_NAME
370
469
  )
371
470
  )
372
471
  if group_all.scalars().one_or_none() is None:
373
- first_group = UserGroup(name=FRACTAL_DEFAULT_GROUP_NAME)
472
+ first_group = UserGroup(name=settings.FRACTAL_DEFAULT_GROUP_NAME)
374
473
  db.add(first_group)
375
474
  db.commit()
376
475
  function_logger.info(
377
- f"Created group '{FRACTAL_DEFAULT_GROUP_NAME}'"
476
+ f"Created group '{settings.FRACTAL_DEFAULT_GROUP_NAME}'"
378
477
  )
379
478
  else:
380
479
  function_logger.info(
381
- f"Group '{FRACTAL_DEFAULT_GROUP_NAME}' already exists, skip."
480
+ f"Group '{settings.FRACTAL_DEFAULT_GROUP_NAME}' "
481
+ "already exists, skip."
382
482
  )
383
- function_logger.info(
384
- f"END _create_first_group, with name '{FRACTAL_DEFAULT_GROUP_NAME}'"
385
- )
483
+ function_logger.info("END")
484
+ close_logger(function_logger)
@@ -2,47 +2,65 @@ import smtplib
2
2
  from email.message import EmailMessage
3
3
  from email.utils import formataddr
4
4
 
5
- from cryptography.fernet import Fernet
5
+ from fractal_server.config import PublicEmailSettings
6
+ from fractal_server.logger import set_logger
6
7
 
7
- from fractal_server.config import MailSettings
8
+ logger = set_logger(__name__)
8
9
 
9
10
 
10
- def mail_new_oauth_signup(msg: str, email_settings: MailSettings):
11
+ def send_fractal_email_or_log_failure(
12
+ *,
13
+ subject: str,
14
+ msg: str,
15
+ email_settings: PublicEmailSettings | None,
16
+ ):
11
17
  """
12
- Send an email using the specified settings to notify a new OAuth signup.
18
+ Send an email using the specified settings, or log about failure.
13
19
  """
14
20
 
15
- mail_msg = EmailMessage()
16
- mail_msg.set_content(msg)
17
- mail_msg["From"] = formataddr(
18
- (email_settings.sender, email_settings.sender)
19
- )
20
- mail_msg["To"] = ", ".join(
21
- [
22
- formataddr((recipient, recipient))
23
- for recipient in email_settings.recipients
24
- ]
25
- )
26
- mail_msg[
27
- "Subject"
28
- ] = f"[Fractal, {email_settings.instance_name}] New OAuth signup"
21
+ if email_settings is None:
22
+ logger.error(
23
+ f"Cannot send email with {subject=}, because {email_settings=}."
24
+ )
29
25
 
30
- with smtplib.SMTP(
31
- email_settings.smtp_server, email_settings.port
32
- ) as server:
33
- server.ehlo()
34
- if email_settings.use_starttls:
35
- server.starttls()
26
+ try:
27
+ logger.info(f"START sending email with {subject=}.")
28
+ mail_msg = EmailMessage()
29
+ mail_msg.set_content(msg)
30
+ mail_msg["From"] = formataddr(
31
+ (email_settings.sender, email_settings.sender)
32
+ )
33
+ mail_msg["To"] = ", ".join(
34
+ [
35
+ formataddr((recipient, recipient))
36
+ for recipient in email_settings.recipients
37
+ ]
38
+ )
39
+ mail_msg[
40
+ "Subject"
41
+ ] = f"[Fractal, {email_settings.instance_name}] {subject}"
42
+ with smtplib.SMTP(
43
+ email_settings.smtp_server,
44
+ email_settings.port,
45
+ ) as server:
36
46
  server.ehlo()
37
- if email_settings.use_login:
38
- password = (
39
- Fernet(email_settings.encryption_key.get_secret_value())
40
- .decrypt(email_settings.encrypted_password.get_secret_value())
41
- .decode("utf-8")
47
+ if email_settings.use_starttls:
48
+ server.starttls()
49
+ server.ehlo()
50
+ if email_settings.use_login:
51
+ server.login(
52
+ user=email_settings.sender,
53
+ password=email_settings.password.get_secret_value(),
54
+ )
55
+ server.sendmail(
56
+ from_addr=email_settings.sender,
57
+ to_addrs=email_settings.recipients,
58
+ msg=mail_msg.as_string(),
42
59
  )
43
- server.login(user=email_settings.sender, password=password)
44
- server.sendmail(
45
- from_addr=email_settings.sender,
46
- to_addrs=email_settings.recipients,
47
- msg=mail_msg.as_string(),
60
+ logger.info(f"END sending email with {subject=}.")
61
+
62
+ except Exception as e:
63
+ logger.error(
64
+ "Could not send self-registration email, "
65
+ f"original error: {str(e)}."
48
66
  )
@@ -0,0 +1,27 @@
1
+ from ._data import DataAuthScheme # noqa F401
2
+ from ._data import DataSettings
3
+ from ._database import DatabaseSettings
4
+ from ._email import EmailSettings
5
+ from ._email import PublicEmailSettings # noqa F401
6
+ from ._main import Settings
7
+ from ._oauth import OAuthSettings
8
+
9
+
10
+ def get_db_settings(db_settings=DatabaseSettings()) -> DatabaseSettings:
11
+ return db_settings
12
+
13
+
14
+ def get_settings(settings=Settings()) -> Settings:
15
+ return settings
16
+
17
+
18
+ def get_email_settings(email_settings=EmailSettings()) -> EmailSettings:
19
+ return email_settings
20
+
21
+
22
+ def get_oauth_settings(oauth_settings=OAuthSettings()) -> OAuthSettings:
23
+ return oauth_settings
24
+
25
+
26
+ def get_data_settings(data_settings=DataSettings()) -> DataSettings:
27
+ return data_settings