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.
- fractal_server/__init__.py +1 -1
- fractal_server/__main__.py +178 -52
- fractal_server/app/db/__init__.py +9 -11
- fractal_server/app/models/security.py +30 -22
- fractal_server/app/models/user_settings.py +5 -4
- fractal_server/app/models/v2/__init__.py +4 -0
- fractal_server/app/models/v2/profile.py +16 -0
- fractal_server/app/models/v2/project.py +5 -0
- fractal_server/app/models/v2/resource.py +130 -0
- fractal_server/app/models/v2/task_group.py +4 -0
- fractal_server/app/routes/admin/v2/__init__.py +4 -0
- fractal_server/app/routes/admin/v2/_aux_functions.py +55 -0
- fractal_server/app/routes/admin/v2/accounting.py +3 -3
- fractal_server/app/routes/admin/v2/impersonate.py +2 -2
- fractal_server/app/routes/admin/v2/job.py +51 -15
- fractal_server/app/routes/admin/v2/profile.py +100 -0
- fractal_server/app/routes/admin/v2/project.py +2 -2
- fractal_server/app/routes/admin/v2/resource.py +222 -0
- fractal_server/app/routes/admin/v2/task.py +59 -32
- fractal_server/app/routes/admin/v2/task_group.py +17 -12
- fractal_server/app/routes/admin/v2/task_group_lifecycle.py +52 -86
- fractal_server/app/routes/api/__init__.py +45 -8
- fractal_server/app/routes/api/v2/_aux_functions.py +17 -1
- fractal_server/app/routes/api/v2/_aux_functions_history.py +2 -2
- fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
- fractal_server/app/routes/api/v2/_aux_functions_tasks.py +55 -19
- fractal_server/app/routes/api/v2/_aux_task_group_disambiguation.py +21 -17
- fractal_server/app/routes/api/v2/dataset.py +10 -19
- fractal_server/app/routes/api/v2/history.py +8 -8
- fractal_server/app/routes/api/v2/images.py +5 -5
- fractal_server/app/routes/api/v2/job.py +8 -8
- fractal_server/app/routes/api/v2/pre_submission_checks.py +3 -3
- fractal_server/app/routes/api/v2/project.py +15 -7
- fractal_server/app/routes/api/v2/status_legacy.py +2 -2
- fractal_server/app/routes/api/v2/submit.py +49 -42
- fractal_server/app/routes/api/v2/task.py +26 -8
- fractal_server/app/routes/api/v2/task_collection.py +39 -50
- fractal_server/app/routes/api/v2/task_collection_custom.py +10 -6
- fractal_server/app/routes/api/v2/task_collection_pixi.py +34 -42
- fractal_server/app/routes/api/v2/task_group.py +19 -9
- fractal_server/app/routes/api/v2/task_group_lifecycle.py +43 -86
- fractal_server/app/routes/api/v2/task_version_update.py +3 -3
- fractal_server/app/routes/api/v2/workflow.py +9 -9
- fractal_server/app/routes/api/v2/workflow_import.py +25 -13
- fractal_server/app/routes/api/v2/workflowtask.py +5 -5
- fractal_server/app/routes/auth/__init__.py +34 -5
- fractal_server/app/routes/auth/_aux_auth.py +39 -20
- fractal_server/app/routes/auth/current_user.py +56 -67
- fractal_server/app/routes/auth/group.py +29 -46
- fractal_server/app/routes/auth/oauth.py +55 -38
- fractal_server/app/routes/auth/register.py +2 -2
- fractal_server/app/routes/auth/router.py +4 -2
- fractal_server/app/routes/auth/users.py +29 -53
- fractal_server/app/routes/aux/_runner.py +2 -1
- fractal_server/app/routes/aux/validate_user_profile.py +62 -0
- fractal_server/app/schemas/__init__.py +0 -1
- fractal_server/app/schemas/user.py +43 -13
- fractal_server/app/schemas/user_group.py +2 -1
- fractal_server/app/schemas/v2/__init__.py +12 -0
- fractal_server/app/schemas/v2/profile.py +78 -0
- fractal_server/app/schemas/v2/resource.py +137 -0
- fractal_server/app/schemas/v2/task_collection.py +11 -3
- fractal_server/app/schemas/v2/task_group.py +5 -0
- fractal_server/app/security/__init__.py +174 -75
- fractal_server/app/security/signup_email.py +52 -34
- fractal_server/config/__init__.py +27 -0
- fractal_server/config/_data.py +68 -0
- fractal_server/config/_database.py +59 -0
- fractal_server/config/_email.py +133 -0
- fractal_server/config/_main.py +78 -0
- fractal_server/config/_oauth.py +69 -0
- fractal_server/config/_settings_config.py +7 -0
- fractal_server/data_migrations/2_17_0.py +339 -0
- fractal_server/images/tools.py +3 -3
- fractal_server/logger.py +3 -3
- fractal_server/main.py +17 -23
- fractal_server/migrations/naming_convention.py +1 -1
- fractal_server/migrations/versions/83bc2ad3ffcc_2_17_0.py +195 -0
- fractal_server/runner/config/__init__.py +2 -0
- fractal_server/runner/config/_local.py +21 -0
- fractal_server/runner/config/_slurm.py +129 -0
- fractal_server/runner/config/slurm_mem_to_MB.py +63 -0
- fractal_server/runner/exceptions.py +4 -0
- fractal_server/runner/executors/base_runner.py +17 -7
- fractal_server/runner/executors/local/get_local_config.py +21 -86
- fractal_server/runner/executors/local/runner.py +48 -5
- fractal_server/runner/executors/slurm_common/_batching.py +2 -2
- fractal_server/runner/executors/slurm_common/base_slurm_runner.py +60 -26
- fractal_server/runner/executors/slurm_common/get_slurm_config.py +39 -55
- fractal_server/runner/executors/slurm_common/remote.py +1 -1
- fractal_server/runner/executors/slurm_common/slurm_config.py +214 -0
- fractal_server/runner/executors/slurm_common/slurm_job_task_models.py +1 -1
- fractal_server/runner/executors/slurm_ssh/runner.py +12 -14
- fractal_server/runner/executors/slurm_sudo/_subprocess_run_as_user.py +2 -2
- fractal_server/runner/executors/slurm_sudo/runner.py +12 -12
- fractal_server/runner/v2/_local.py +36 -21
- fractal_server/runner/v2/_slurm_ssh.py +41 -4
- fractal_server/runner/v2/_slurm_sudo.py +42 -12
- fractal_server/runner/v2/db_tools.py +1 -1
- fractal_server/runner/v2/runner.py +3 -11
- fractal_server/runner/v2/runner_functions.py +42 -28
- fractal_server/runner/v2/submit_workflow.py +88 -109
- fractal_server/runner/versions.py +8 -3
- fractal_server/ssh/_fabric.py +6 -6
- fractal_server/tasks/config/__init__.py +3 -0
- fractal_server/tasks/config/_pixi.py +127 -0
- fractal_server/tasks/config/_python.py +51 -0
- fractal_server/tasks/v2/local/_utils.py +7 -7
- fractal_server/tasks/v2/local/collect.py +13 -5
- fractal_server/tasks/v2/local/collect_pixi.py +26 -10
- fractal_server/tasks/v2/local/deactivate.py +7 -1
- fractal_server/tasks/v2/local/deactivate_pixi.py +5 -1
- fractal_server/tasks/v2/local/delete.py +5 -1
- fractal_server/tasks/v2/local/reactivate.py +13 -5
- fractal_server/tasks/v2/local/reactivate_pixi.py +27 -9
- fractal_server/tasks/v2/ssh/_pixi_slurm_ssh.py +11 -10
- fractal_server/tasks/v2/ssh/_utils.py +6 -7
- fractal_server/tasks/v2/ssh/collect.py +19 -12
- fractal_server/tasks/v2/ssh/collect_pixi.py +34 -16
- fractal_server/tasks/v2/ssh/deactivate.py +12 -8
- fractal_server/tasks/v2/ssh/deactivate_pixi.py +14 -10
- fractal_server/tasks/v2/ssh/delete.py +12 -9
- fractal_server/tasks/v2/ssh/reactivate.py +18 -12
- fractal_server/tasks/v2/ssh/reactivate_pixi.py +36 -17
- fractal_server/tasks/v2/templates/4_pip_show.sh +4 -6
- fractal_server/tasks/v2/utils_database.py +2 -2
- fractal_server/tasks/v2/utils_pixi.py +3 -0
- fractal_server/tasks/v2/utils_python_interpreter.py +8 -16
- fractal_server/tasks/v2/utils_templates.py +7 -10
- fractal_server/utils.py +1 -1
- {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/METADATA +4 -6
- {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/RECORD +136 -117
- fractal_server/app/routes/aux/validate_user_settings.py +0 -73
- fractal_server/app/schemas/user_settings.py +0 -67
- fractal_server/app/user_settings.py +0 -42
- fractal_server/config.py +0 -906
- fractal_server/data_migrations/2_14_10.py +0 -48
- fractal_server/runner/executors/slurm_common/_slurm_config.py +0 -471
- /fractal_server/{runner → app}/shutdown.py +0 -0
- {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/WHEEL +0 -0
- {fractal_server-2.16.6.dist-info → fractal_server-2.17.0.dist-info}/entry_points.txt +0 -0
- {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:
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
if
|
|
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=
|
|
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
|
|
351
|
+
f"Added {user.email} user to group "
|
|
352
|
+
f"{default_group_id_or_none=}."
|
|
237
353
|
)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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}'
|
|
480
|
+
f"Group '{settings.FRACTAL_DEFAULT_GROUP_NAME}' "
|
|
481
|
+
"already exists, skip."
|
|
382
482
|
)
|
|
383
|
-
function_logger.info(
|
|
384
|
-
|
|
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
|
|
5
|
+
from fractal_server.config import PublicEmailSettings
|
|
6
|
+
from fractal_server.logger import set_logger
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
logger = set_logger(__name__)
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def
|
|
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
|
|
18
|
+
Send an email using the specified settings, or log about failure.
|
|
13
19
|
"""
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|