fractal-server 2.17.0a3__py3-none-any.whl → 2.17.0a5__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 (65) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +137 -120
  3. fractal_server/app/models/security.py +19 -21
  4. fractal_server/app/models/user_settings.py +1 -0
  5. fractal_server/app/models/v2/task_group.py +1 -0
  6. fractal_server/app/routes/admin/v2/accounting.py +3 -3
  7. fractal_server/app/routes/admin/v2/impersonate.py +2 -2
  8. fractal_server/app/routes/admin/v2/job.py +6 -6
  9. fractal_server/app/routes/admin/v2/profile.py +4 -4
  10. fractal_server/app/routes/admin/v2/project.py +2 -2
  11. fractal_server/app/routes/admin/v2/resource.py +42 -8
  12. fractal_server/app/routes/admin/v2/task.py +2 -2
  13. fractal_server/app/routes/admin/v2/task_group.py +5 -5
  14. fractal_server/app/routes/admin/v2/task_group_lifecycle.py +4 -4
  15. fractal_server/app/routes/api/__init__.py +5 -5
  16. fractal_server/app/routes/api/v2/dataset.py +10 -19
  17. fractal_server/app/routes/api/v2/history.py +8 -8
  18. fractal_server/app/routes/api/v2/images.py +5 -5
  19. fractal_server/app/routes/api/v2/job.py +8 -8
  20. fractal_server/app/routes/api/v2/pre_submission_checks.py +3 -3
  21. fractal_server/app/routes/api/v2/project.py +6 -6
  22. fractal_server/app/routes/api/v2/status_legacy.py +2 -2
  23. fractal_server/app/routes/api/v2/submit.py +24 -26
  24. fractal_server/app/routes/api/v2/task.py +6 -7
  25. fractal_server/app/routes/api/v2/task_collection.py +4 -3
  26. fractal_server/app/routes/api/v2/task_collection_custom.py +4 -3
  27. fractal_server/app/routes/api/v2/task_collection_pixi.py +2 -2
  28. fractal_server/app/routes/api/v2/task_group.py +6 -6
  29. fractal_server/app/routes/api/v2/task_group_lifecycle.py +4 -4
  30. fractal_server/app/routes/api/v2/task_version_update.py +3 -3
  31. fractal_server/app/routes/api/v2/workflow.py +9 -9
  32. fractal_server/app/routes/api/v2/workflow_import.py +2 -2
  33. fractal_server/app/routes/api/v2/workflowtask.py +5 -5
  34. fractal_server/app/routes/auth/__init__.py +34 -5
  35. fractal_server/app/routes/auth/current_user.py +22 -67
  36. fractal_server/app/routes/auth/group.py +8 -35
  37. fractal_server/app/routes/auth/register.py +2 -2
  38. fractal_server/app/routes/auth/users.py +5 -46
  39. fractal_server/app/schemas/__init__.py +0 -1
  40. fractal_server/app/schemas/user.py +23 -0
  41. fractal_server/app/schemas/v2/task_group.py +1 -0
  42. fractal_server/app/security/__init__.py +134 -46
  43. fractal_server/app/security/signup_email.py +52 -34
  44. fractal_server/config/__init__.py +1 -7
  45. fractal_server/config/_email.py +10 -47
  46. fractal_server/config/_main.py +14 -3
  47. fractal_server/migrations/versions/f65ee53991e3_user_settings_related.py +67 -0
  48. fractal_server/runner/config/_slurm.py +3 -2
  49. fractal_server/runner/executors/slurm_common/base_slurm_runner.py +2 -2
  50. fractal_server/runner/executors/slurm_common/get_slurm_config.py +1 -1
  51. fractal_server/runner/executors/slurm_common/slurm_config.py +7 -13
  52. fractal_server/runner/executors/slurm_ssh/runner.py +1 -1
  53. fractal_server/runner/executors/slurm_sudo/runner.py +1 -1
  54. fractal_server/runner/v2/_slurm_ssh.py +2 -1
  55. fractal_server/runner/v2/_slurm_sudo.py +1 -1
  56. fractal_server/runner/v2/submit_workflow.py +12 -12
  57. {fractal_server-2.17.0a3.dist-info → fractal_server-2.17.0a5.dist-info}/METADATA +1 -2
  58. {fractal_server-2.17.0a3.dist-info → fractal_server-2.17.0a5.dist-info}/RECORD +61 -64
  59. fractal_server/app/routes/aux/validate_user_settings.py +0 -76
  60. fractal_server/app/schemas/user_settings.py +0 -63
  61. fractal_server/app/user_settings.py +0 -32
  62. fractal_server/config/_init_data.py +0 -27
  63. {fractal_server-2.17.0a3.dist-info → fractal_server-2.17.0a5.dist-info}/WHEEL +0 -0
  64. {fractal_server-2.17.0a3.dist-info → fractal_server-2.17.0a5.dist-info}/entry_points.txt +0 -0
  65. {fractal_server-2.17.0a3.dist-info → fractal_server-2.17.0a5.dist-info}/licenses/LICENSE +0 -0
@@ -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,10 +45,12 @@ 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
+ )
61
52
  from fractal_server.config import get_email_settings
53
+ from fractal_server.config import get_settings
62
54
  from fractal_server.logger import set_logger
63
55
  from fractal_server.syringe import Inject
64
56
 
@@ -209,6 +201,131 @@ class UserManager(IntegerIDMixin, BaseUserManager[UserOAuth, int]):
209
201
  f"The password is too long (maximum length: {min_length})."
210
202
  )
211
203
 
204
+ async def oauth_callback(
205
+ self: Self,
206
+ oauth_name: str,
207
+ access_token: str,
208
+ account_id: str,
209
+ account_email: str,
210
+ expires_at: int | None = None,
211
+ refresh_token: str | None = None,
212
+ request: Request | None = None,
213
+ *,
214
+ associate_by_email: bool = False,
215
+ is_verified_by_default: bool = False,
216
+ ) -> UserOAuth:
217
+ """
218
+ Handle the callback after a successful OAuth authentication.
219
+
220
+ This method extends the corresponding `BaseUserManager` method of
221
+ > fastapi-users v14.0.1, Copyright (c) 2019 François Voron, MIT License
222
+
223
+ If the user already exists with this OAuth account, the token is
224
+ updated.
225
+
226
+ If a user with the same e-mail already exists and `associate_by_email`
227
+ is True, the OAuth account is associated to this user.
228
+ Otherwise, the `UserNotExists` exception is raised.
229
+
230
+ If the user does not exist, send an email to the Fractal admins (if
231
+ configured) and respond with a 400 error status. NOTE: This is the
232
+ function branch where the `fractal-server` implementation deviates
233
+ from the original `fastapi-users` one.
234
+
235
+ :param oauth_name: Name of the OAuth client.
236
+ :param access_token: Valid access token for the service provider.
237
+ :param account_id: models.ID of the user on the service provider.
238
+ :param account_email: E-mail of the user on the service provider.
239
+ :param expires_at: Optional timestamp at which the access token
240
+ expires.
241
+ :param refresh_token: Optional refresh token to get a
242
+ fresh access token from the service provider.
243
+ :param request: Optional FastAPI request that
244
+ triggered the operation, defaults to None
245
+ :param associate_by_email: If True, any existing user with the same
246
+ e-mail address will be associated to this user. Defaults to False.
247
+ :param is_verified_by_default: If True, the `is_verified` flag will be
248
+ set to `True` on newly created user. Make sure the OAuth Provider you
249
+ are using does verify the email address before enabling this flag.
250
+ Defaults to False.
251
+ :return: A user.
252
+ """
253
+ from fastapi import HTTPException
254
+ from fastapi import status
255
+ from fastapi_users import exceptions
256
+
257
+ oauth_account_dict = {
258
+ "oauth_name": oauth_name,
259
+ "access_token": access_token,
260
+ "account_id": account_id,
261
+ "account_email": account_email,
262
+ "expires_at": expires_at,
263
+ "refresh_token": refresh_token,
264
+ }
265
+
266
+ try:
267
+ user = await self.get_by_oauth_account(oauth_name, account_id)
268
+ except exceptions.UserNotExists:
269
+ try:
270
+ # Associate account
271
+ user = await self.get_by_email(account_email)
272
+ if not associate_by_email:
273
+ raise exceptions.UserAlreadyExists()
274
+ user = await self.user_db.add_oauth_account(
275
+ user, oauth_account_dict
276
+ )
277
+ except exceptions.UserNotExists:
278
+ # (0) Log
279
+ logger.warning(
280
+ f"Self-registration attempt by {account_email}."
281
+ )
282
+
283
+ # (1) Prepare user-facing error message
284
+ settings = Inject(get_settings)
285
+ error_msg = (
286
+ "Thank you for registering for the Fractal service. "
287
+ "Administrators have been informed to configure your "
288
+ "account and will get back to you."
289
+ )
290
+ if settings.FRACTAL_HELP_URL is not None:
291
+ error_msg = (
292
+ f"{error_msg}\n"
293
+ "You can find more information about the onboarding "
294
+ f"process at {settings.FRACTAL_HELP_URL}."
295
+ )
296
+
297
+ # (2) Send email to admins
298
+ email_settings = Inject(get_email_settings)
299
+ send_fractal_email_or_log_failure(
300
+ subject="New OAuth self-registration",
301
+ msg=(
302
+ f"User '{account_email}' tried to "
303
+ "self-register through OAuth.\n"
304
+ "Please create the Fractal account manually.\n"
305
+ "Here is the error message displayed to the "
306
+ f"user:\n{error_msg}"
307
+ ),
308
+ email_settings=email_settings.public,
309
+ )
310
+
311
+ # (3) Raise
312
+ raise HTTPException(
313
+ status_code=status.HTTP_400_BAD_REQUEST,
314
+ detail=error_msg,
315
+ )
316
+ else:
317
+ # Update oauth
318
+ for existing_oauth_account in user.oauth_accounts:
319
+ if (
320
+ existing_oauth_account.account_id == account_id
321
+ and existing_oauth_account.oauth_name == oauth_name
322
+ ):
323
+ user = await self.user_db.update_oauth_account(
324
+ user, existing_oauth_account, oauth_account_dict
325
+ )
326
+
327
+ return user
328
+
212
329
  async def on_after_register(
213
330
  self, user: UserOAuth, request: Request | None = None
214
331
  ):
@@ -216,7 +333,6 @@ class UserManager(IntegerIDMixin, BaseUserManager[UserOAuth, int]):
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
336
  stm = select(UserGroup).where(
221
337
  UserGroup.name == FRACTAL_DEFAULT_GROUP_NAME
222
338
  )
@@ -236,38 +352,6 @@ class UserManager(IntegerIDMixin, BaseUserManager[UserOAuth, int]):
236
352
  f"Added {user.email} user to group {default_group.id=}."
237
353
  )
238
354
 
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
- email_settings = Inject(get_email_settings)
252
-
253
- if this_user.oauth_accounts and email_settings.public is not None:
254
- try:
255
- logger.info(
256
- "START sending email about new signup to "
257
- f"{email_settings.public.recipients}."
258
- )
259
- mail_new_oauth_signup(
260
- msg=f"New user registered: '{this_user.email}'.",
261
- email_settings=email_settings.public,
262
- )
263
- logger.info("END sending email about new signup.")
264
- except Exception as e:
265
- logger.error(
266
- "ERROR sending notification email after oauth "
267
- f"registration of {this_user.email}. "
268
- f"Original error: '{e}'."
269
- )
270
-
271
355
 
272
356
  async def get_user_manager(
273
357
  user_db: SQLModelUserDatabaseAsync = Depends(get_user_db),
@@ -283,6 +367,8 @@ get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
283
367
  async def _create_first_user(
284
368
  email: str,
285
369
  password: str,
370
+ project_dir: str,
371
+ profile_id: int | None = None,
286
372
  is_superuser: bool = False,
287
373
  is_verified: bool = False,
288
374
  ) -> None:
@@ -331,6 +417,8 @@ async def _create_first_user(
331
417
  kwargs = dict(
332
418
  email=email,
333
419
  password=password,
420
+ project_dir=project_dir,
421
+ profile_id=profile_id,
334
422
  is_superuser=is_superuser,
335
423
  is_verified=is_verified,
336
424
  )
@@ -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
6
-
7
5
  from fractal_server.config import PublicEmailSettings
6
+ from fractal_server.logger import set_logger
7
+
8
+ logger = set_logger(__name__)
8
9
 
9
10
 
10
- def mail_new_oauth_signup(msg: str, email_settings: PublicEmailSettings):
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
  )
@@ -1,8 +1,8 @@
1
1
  from ._database import DatabaseSettings
2
2
  from ._email import EmailSettings
3
3
  from ._email import PublicEmailSettings # noqa F401
4
- from ._init_data import InitDataSettings
5
4
  from ._main import Settings
5
+ from ._main import ViewerAuthScheme # noqa F401
6
6
  from ._oauth import OAuthSettings
7
7
 
8
8
 
@@ -18,11 +18,5 @@ def get_email_settings(email_settings=EmailSettings()) -> EmailSettings:
18
18
  return email_settings
19
19
 
20
20
 
21
- def get_init_data_settings(
22
- init_data_settings=InitDataSettings(),
23
- ) -> InitDataSettings:
24
- return init_data_settings
25
-
26
-
27
21
  def get_oauth_settings(oauth_settings=OAuthSettings()) -> OAuthSettings:
28
22
  return oauth_settings
@@ -1,6 +1,6 @@
1
1
  from typing import Literal
2
+ from typing import Self
2
3
 
3
- from cryptography.fernet import Fernet
4
4
  from pydantic import BaseModel
5
5
  from pydantic import EmailStr
6
6
  from pydantic import Field
@@ -31,8 +31,7 @@ class PublicEmailSettings(BaseModel):
31
31
  recipients: list[EmailStr] = Field(min_length=1)
32
32
  smtp_server: str
33
33
  port: int
34
- encrypted_password: SecretStr | None = None
35
- encryption_key: SecretStr | None = None
34
+ password: SecretStr | None = None
36
35
  instance_name: str
37
36
  use_starttls: bool
38
37
  use_login: bool
@@ -53,10 +52,6 @@ class EmailSettings(BaseSettings):
53
52
  """
54
53
  Password for the OAuth-signup email sender.
55
54
  """
56
- FRACTAL_EMAIL_PASSWORD_KEY: SecretStr | None = None
57
- """
58
- Key value for `cryptography.fernet` decrypt
59
- """
60
55
  FRACTAL_EMAIL_SMTP_SERVER: str | None = None
61
56
  """
62
57
  SMTP server for the OAuth-signup emails.
@@ -81,8 +76,7 @@ class EmailSettings(BaseSettings):
81
76
  FRACTAL_EMAIL_USE_LOGIN: Literal["true", "false"] = "true"
82
77
  """
83
78
  Whether to use login when using the SMTP server.
84
- If 'true', FRACTAL_EMAIL_PASSWORD and FRACTAL_EMAIL_PASSWORD_KEY must be
85
- provided.
79
+ If 'true', FRACTAL_EMAIL_PASSWORD must be provided.
86
80
  Accepted values: 'true', 'false'.
87
81
  """
88
82
 
@@ -93,7 +87,7 @@ class EmailSettings(BaseSettings):
93
87
  """
94
88
 
95
89
  @model_validator(mode="after")
96
- def validate_email_settings(self):
90
+ def validate_email_settings(self: Self) -> Self:
97
91
  """
98
92
  Set `self.public`.
99
93
  """
@@ -119,49 +113,18 @@ class EmailSettings(BaseSettings):
119
113
  use_starttls = self.FRACTAL_EMAIL_USE_STARTTLS == "true"
120
114
  use_login = self.FRACTAL_EMAIL_USE_LOGIN == "true"
121
115
 
122
- if use_login:
123
- if self.FRACTAL_EMAIL_PASSWORD is None:
124
- raise ValueError(
125
- "'FRACTAL_EMAIL_USE_LOGIN' is 'true' but "
126
- "'FRACTAL_EMAIL_PASSWORD' is not provided."
127
- )
128
- if self.FRACTAL_EMAIL_PASSWORD_KEY is None:
129
- raise ValueError(
130
- "'FRACTAL_EMAIL_USE_LOGIN' is 'true' but "
131
- "'FRACTAL_EMAIL_PASSWORD_KEY' is not provided."
132
- )
133
- try:
134
- (
135
- Fernet(
136
- self.FRACTAL_EMAIL_PASSWORD_KEY.get_secret_value()
137
- )
138
- .decrypt(
139
- self.FRACTAL_EMAIL_PASSWORD.get_secret_value()
140
- )
141
- .decode("utf-8")
142
- )
143
- except Exception as e:
144
- raise ValueError(
145
- "Invalid pair (FRACTAL_EMAIL_PASSWORD, "
146
- "FRACTAL_EMAIL_PASSWORD_KEY). "
147
- f"Original error: {str(e)}."
148
- )
149
- password = self.FRACTAL_EMAIL_PASSWORD.get_secret_value()
150
- else:
151
- password = None
152
-
153
- if self.FRACTAL_EMAIL_PASSWORD_KEY is not None:
154
- key = self.FRACTAL_EMAIL_PASSWORD_KEY.get_secret_value()
155
- else:
156
- key = None
116
+ if use_login and self.FRACTAL_EMAIL_PASSWORD is None:
117
+ raise ValueError(
118
+ "'FRACTAL_EMAIL_USE_LOGIN' is 'true' but "
119
+ "'FRACTAL_EMAIL_PASSWORD' is not provided."
120
+ )
157
121
 
158
122
  self.public = PublicEmailSettings(
159
123
  sender=self.FRACTAL_EMAIL_SENDER,
160
124
  recipients=self.FRACTAL_EMAIL_RECIPIENTS.split(","),
161
125
  smtp_server=self.FRACTAL_EMAIL_SMTP_SERVER,
162
126
  port=self.FRACTAL_EMAIL_SMTP_PORT,
163
- encrypted_password=password,
164
- encryption_key=key,
127
+ password=self.FRACTAL_EMAIL_PASSWORD,
165
128
  instance_name=self.FRACTAL_EMAIL_INSTANCE_NAME,
166
129
  use_starttls=use_starttls,
167
130
  use_login=use_login,
@@ -1,7 +1,9 @@
1
1
  import logging
2
+ from enum import StrEnum
2
3
  from typing import Literal
3
4
  from typing import TypeVar
4
5
 
6
+ from pydantic import HttpUrl
5
7
  from pydantic import SecretStr
6
8
  from pydantic_settings import BaseSettings
7
9
  from pydantic_settings import SettingsConfigDict
@@ -14,6 +16,12 @@ class FractalConfigurationError(ValueError):
14
16
  pass
15
17
 
16
18
 
19
+ class ViewerAuthScheme(StrEnum):
20
+ VIEWER_PATHS = "viewer-paths"
21
+ USERS_FOLDERS = "users-folders"
22
+ NONE = "none"
23
+
24
+
17
25
  T = TypeVar("T")
18
26
 
19
27
 
@@ -72,9 +80,7 @@ class Settings(BaseSettings):
72
80
  Waiting time for the shutdown phase of executors
73
81
  """
74
82
 
75
- FRACTAL_VIEWER_AUTHORIZATION_SCHEME: Literal[
76
- "viewer-paths", "users-folders", "none"
77
- ] = "none"
83
+ FRACTAL_VIEWER_AUTHORIZATION_SCHEME: ViewerAuthScheme = "none"
78
84
  """
79
85
  Defines how the list of allowed viewer paths is built.
80
86
 
@@ -119,3 +125,8 @@ class Settings(BaseSettings):
119
125
  "FRACTAL_VIEWER_AUTHORIZATION_SCHEME is set to "
120
126
  "users-folders"
121
127
  )
128
+
129
+ FRACTAL_HELP_URL: HttpUrl | None = None
130
+ """
131
+ The URL of an instance-specific Fractal help page.
132
+ """
@@ -0,0 +1,67 @@
1
+ """user-settings-related
2
+
3
+ Revision ID: f65ee53991e3
4
+ Revises: 90f6508c6379
5
+ Create Date: 2025-10-22 15:16:42.217910
6
+
7
+ """
8
+ import sqlalchemy as sa
9
+ from alembic import op
10
+ from sqlalchemy.dialects import postgresql
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = "f65ee53991e3"
14
+ down_revision = "90f6508c6379"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ # ### commands auto generated by Alembic - please adjust! ###
21
+ with op.batch_alter_table("user_oauth", schema=None) as batch_op:
22
+ batch_op.add_column(
23
+ sa.Column(
24
+ "project_dir",
25
+ sa.String(),
26
+ server_default="/PLACEHOLDER",
27
+ nullable=False,
28
+ )
29
+ )
30
+ batch_op.add_column(
31
+ sa.Column(
32
+ "slurm_accounts",
33
+ postgresql.ARRAY(sa.String()),
34
+ server_default="{}",
35
+ nullable=True,
36
+ )
37
+ )
38
+ batch_op.drop_constraint(
39
+ batch_op.f("fk_user_oauth_user_settings_id_user_settings"),
40
+ type_="foreignkey",
41
+ )
42
+ batch_op.drop_column("user_settings_id")
43
+
44
+ # ### end Alembic commands ###
45
+
46
+
47
+ def downgrade() -> None:
48
+ # ### commands auto generated by Alembic - please adjust! ###
49
+ with op.batch_alter_table("user_oauth", schema=None) as batch_op:
50
+ batch_op.add_column(
51
+ sa.Column(
52
+ "user_settings_id",
53
+ sa.INTEGER(),
54
+ autoincrement=False,
55
+ nullable=True,
56
+ )
57
+ )
58
+ batch_op.create_foreign_key(
59
+ batch_op.f("fk_user_oauth_user_settings_id_user_settings"),
60
+ "user_settings",
61
+ ["user_settings_id"],
62
+ ["id"],
63
+ )
64
+ batch_op.drop_column("slurm_accounts")
65
+ batch_op.drop_column("project_dir")
66
+
67
+ # ### end Alembic commands ###
@@ -3,6 +3,7 @@ from typing import Annotated
3
3
  from pydantic import AfterValidator
4
4
  from pydantic import BaseModel
5
5
  from pydantic import ConfigDict
6
+ from pydantic import Field
6
7
  from pydantic.types import PositiveInt
7
8
 
8
9
  from fractal_server.runner.config.slurm_mem_to_MB import slurm_mem_to_MB
@@ -44,7 +45,7 @@ class _SlurmConfigSet(BaseModel):
44
45
  nodelist: NonEmptyStr | None = None
45
46
  time: NonEmptyStr | None = None
46
47
  account: NonEmptyStr | None = None
47
- extra_lines: list[NonEmptyStr] | None = None
48
+ extra_lines: list[NonEmptyStr] = Field(default_factory=list)
48
49
  gpus: NonEmptyStr | None = None
49
50
 
50
51
 
@@ -125,4 +126,4 @@ class JobRunnerConfigSLURM(BaseModel):
125
126
  default_slurm_config: _SlurmConfigSet
126
127
  gpu_slurm_config: _SlurmConfigSet | None = None
127
128
  batching_config: _BatchingConfigSet
128
- user_local_exports: DictStrStr | None = None
129
+ user_local_exports: DictStrStr = Field(default_factory=dict)
@@ -87,7 +87,7 @@ class BaseSlurmRunner(BaseRunner):
87
87
  python_worker_interpreter: str,
88
88
  poll_interval: int,
89
89
  common_script_lines: list[str] | None = None,
90
- user_cache_dir: str | None = None, # FIXME: make required?
90
+ user_cache_dir: str,
91
91
  slurm_account: str | None = None,
92
92
  ):
93
93
  self.slurm_runner_type = slurm_runner_type
@@ -252,7 +252,7 @@ class BaseSlurmRunner(BaseRunner):
252
252
  f"Add {self.common_script_lines} to "
253
253
  f"{new_slurm_config.extra_lines=}."
254
254
  )
255
- current_extra_lines = new_slurm_config.extra_lines or []
255
+ current_extra_lines = new_slurm_config.extra_lines
256
256
  new_slurm_config.extra_lines = (
257
257
  current_extra_lines + self.common_script_lines
258
258
  )
@@ -77,7 +77,7 @@ def _get_slurm_config_internal(
77
77
  else:
78
78
  needs_gpu = False
79
79
  logger.debug(f"[get_slurm_config] {needs_gpu=}")
80
- if needs_gpu:
80
+ if needs_gpu and shared_config.gpu_slurm_config is not None:
81
81
  for key, value in shared_config.gpu_slurm_config.model_dump(
82
82
  exclude_unset=True, exclude={"mem"}
83
83
  ).items():
@@ -83,10 +83,10 @@ class SlurmConfig(BaseModel):
83
83
 
84
84
  # Free-field attribute for extra lines to be added to the SLURM job
85
85
  # preamble
86
- extra_lines: list[str] | None = Field(default_factory=list)
86
+ extra_lines: list[str] = Field(default_factory=list)
87
87
 
88
88
  # Variables that will be `export`ed in the SLURM submission script
89
- user_local_exports: dict[str, str] | None = None
89
+ user_local_exports: dict[str, str] = Field(default_factory=dict)
90
90
 
91
91
  # Metaparameters needed to combine multiple tasks in each SLURM job
92
92
  tasks_per_job: int | None = None
@@ -136,7 +136,7 @@ class SlurmConfig(BaseModel):
136
136
 
137
137
  def to_sbatch_preamble(
138
138
  self,
139
- remote_export_dir: str | None = None,
139
+ remote_export_dir: str,
140
140
  ) -> list[str]:
141
141
  """
142
142
  Compile `SlurmConfig` object into the preamble of a SLURM submission
@@ -152,9 +152,8 @@ class SlurmConfig(BaseModel):
152
152
  "SlurmConfig.sbatch_preamble requires that "
153
153
  f"{self.parallel_tasks_per_job=} is not None."
154
154
  )
155
- if self.extra_lines:
156
- if len(self.extra_lines) != len(set(self.extra_lines)):
157
- raise ValueError(f"{self.extra_lines=} contains repetitions")
155
+ if len(self.extra_lines) != len(set(self.extra_lines)):
156
+ raise ValueError(f"{self.extra_lines=} contains repetitions")
158
157
 
159
158
  mem_per_job_MB = self.parallel_tasks_per_job * self.mem_per_task_MB
160
159
  lines = [
@@ -187,15 +186,10 @@ class SlurmConfig(BaseModel):
187
186
  option = key.replace("_", "-")
188
187
  lines.append(f"{self.prefix} --{option}={value}")
189
188
 
190
- if self.extra_lines:
191
- for line in self._sorted_extra_lines():
192
- lines.append(line)
189
+ for line in self._sorted_extra_lines():
190
+ lines.append(line)
193
191
 
194
192
  if self.user_local_exports:
195
- if remote_export_dir is None:
196
- raise ValueError(
197
- f"remote_export_dir=None but {self.user_local_exports=}"
198
- )
199
193
  for key, value in self.user_local_exports.items():
200
194
  tmp_value = str(Path(remote_export_dir) / value)
201
195
  lines.append(f"export {key}={tmp_value}")
@@ -31,7 +31,7 @@ class SlurmSSHRunner(BaseSlurmRunner):
31
31
  # Specific
32
32
  slurm_account: str | None = None,
33
33
  profile: Profile,
34
- user_cache_dir: str | None = None,
34
+ user_cache_dir: str,
35
35
  fractal_ssh: FractalSSH,
36
36
  ) -> None:
37
37
  """