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
@@ -6,7 +6,9 @@ from fastapi import APIRouter
6
6
  from .accounting import router as accounting_router
7
7
  from .impersonate import router as impersonate_router
8
8
  from .job import router as job_router
9
+ from .profile import router as profile_router
9
10
  from .project import router as project_router
11
+ from .resource import router as resource_router
10
12
  from .task import router as task_router
11
13
  from .task_group import router as task_group_router
12
14
  from .task_group_lifecycle import router as task_group_lifecycle_router
@@ -22,3 +24,5 @@ router_admin_v2.include_router(
22
24
  task_group_lifecycle_router, prefix="/task-group"
23
25
  )
24
26
  router_admin_v2.include_router(impersonate_router, prefix="/impersonate")
27
+ router_admin_v2.include_router(resource_router, prefix="/resource")
28
+ router_admin_v2.include_router(profile_router, prefix="/profile")
@@ -0,0 +1,55 @@
1
+ from fastapi import HTTPException
2
+ from fastapi import status
3
+ from sqlmodel import select
4
+
5
+ from fractal_server.app.db import AsyncSession
6
+ from fractal_server.app.models.v2 import Profile
7
+ from fractal_server.app.models.v2 import Resource
8
+
9
+
10
+ async def _get_resource_or_404(
11
+ *,
12
+ resource_id: int,
13
+ db: AsyncSession,
14
+ ) -> Resource:
15
+ resource = await db.get(Resource, resource_id)
16
+ if resource is None:
17
+ raise HTTPException(
18
+ status_code=status.HTTP_404_NOT_FOUND,
19
+ detail=f"Resource {resource_id} not found",
20
+ )
21
+ return resource
22
+
23
+
24
+ async def _get_profile_or_404(
25
+ *,
26
+ profile_id: int,
27
+ db: AsyncSession,
28
+ ) -> Profile:
29
+ profile = await db.get(Profile, profile_id)
30
+ if profile is None:
31
+ raise HTTPException(
32
+ status_code=status.HTTP_404_NOT_FOUND,
33
+ detail=f"Profile {profile_id} not found",
34
+ )
35
+ return profile
36
+
37
+
38
+ async def _check_profile_name(*, name: str, db: AsyncSession) -> None:
39
+ res = await db.execute(select(Profile).where(Profile.name == name))
40
+ namesake = res.scalars().one_or_none()
41
+ if namesake is not None:
42
+ raise HTTPException(
43
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
44
+ detail=f"Profile with name '{name}' already exists.",
45
+ )
46
+
47
+
48
+ async def _check_resource_name(*, name: str, db: AsyncSession) -> None:
49
+ res = await db.execute(select(Resource).where(Resource.name == name))
50
+ namesake = res.scalars().one_or_none()
51
+ if namesake is not None:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
54
+ detail=f"Resource with name '{name}' already exists.",
55
+ )
@@ -13,7 +13,7 @@ from fractal_server.app.db import get_async_db
13
13
  from fractal_server.app.models import UserOAuth
14
14
  from fractal_server.app.models.v2 import AccountingRecord
15
15
  from fractal_server.app.models.v2 import AccountingRecordSlurm
16
- from fractal_server.app.routes.auth import current_active_superuser
16
+ from fractal_server.app.routes.auth import current_superuser_act
17
17
  from fractal_server.app.routes.pagination import get_pagination_params
18
18
  from fractal_server.app.routes.pagination import PaginationRequest
19
19
  from fractal_server.app.routes.pagination import PaginationResponse
@@ -34,7 +34,7 @@ async def query_accounting(
34
34
  query: AccountingQuery,
35
35
  # Dependencies
36
36
  pagination: PaginationRequest = Depends(get_pagination_params),
37
- superuser: UserOAuth = Depends(current_active_superuser),
37
+ superuser: UserOAuth = Depends(current_superuser_act),
38
38
  db: AsyncSession = Depends(get_async_db),
39
39
  ) -> PaginationResponse[AccountingRecordRead]:
40
40
  page = pagination.page
@@ -79,7 +79,7 @@ async def query_accounting(
79
79
  async def query_accounting_slurm(
80
80
  query: AccountingQuery,
81
81
  # dependencies
82
- superuser: UserOAuth = Depends(current_active_superuser),
82
+ superuser: UserOAuth = Depends(current_superuser_act),
83
83
  db: AsyncSession = Depends(get_async_db),
84
84
  ) -> JSONResponse:
85
85
  stm = select(AccountingRecordSlurm.slurm_job_ids)
@@ -6,7 +6,7 @@ from fastapi_users.authentication import JWTStrategy
6
6
  from fractal_server.app.db import AsyncSession
7
7
  from fractal_server.app.db import get_async_db
8
8
  from fractal_server.app.models import UserOAuth
9
- from fractal_server.app.routes.auth import current_active_superuser
9
+ from fractal_server.app.routes.auth import current_superuser_act
10
10
  from fractal_server.app.routes.auth._aux_auth import _user_or_404
11
11
  from fractal_server.config import get_settings
12
12
  from fractal_server.syringe import Inject
@@ -17,7 +17,7 @@ router = APIRouter()
17
17
  @router.get("/{user_id}/")
18
18
  async def impersonate_user(
19
19
  user_id: int,
20
- superuser: UserOAuth = Depends(current_active_superuser),
20
+ superuser: UserOAuth = Depends(current_superuser_act),
21
21
  db: AsyncSession = Depends(get_async_db),
22
22
  ) -> JSONResponse:
23
23
  user = await _user_or_404(user_id, db)
@@ -7,6 +7,7 @@ from fastapi import Response
7
7
  from fastapi import status
8
8
  from fastapi.responses import StreamingResponse
9
9
  from pydantic.types import AwareDatetime
10
+ from sqlalchemy import func
10
11
  from sqlmodel import select
11
12
 
12
13
  from fractal_server.app.db import AsyncSession
@@ -16,9 +17,12 @@ from fractal_server.app.models.v2 import HistoryRun
16
17
  from fractal_server.app.models.v2 import HistoryUnit
17
18
  from fractal_server.app.models.v2 import JobV2
18
19
  from fractal_server.app.models.v2 import ProjectV2
19
- from fractal_server.app.routes.auth import current_active_superuser
20
+ from fractal_server.app.routes.auth import current_superuser_act
20
21
  from fractal_server.app.routes.aux._job import _write_shutdown_file
21
22
  from fractal_server.app.routes.aux._runner import _check_shutdown_is_supported
23
+ from fractal_server.app.routes.pagination import get_pagination_params
24
+ from fractal_server.app.routes.pagination import PaginationRequest
25
+ from fractal_server.app.routes.pagination import PaginationResponse
22
26
  from fractal_server.app.schemas.v2 import HistoryUnitStatus
23
27
  from fractal_server.app.schemas.v2 import JobReadV2
24
28
  from fractal_server.app.schemas.v2 import JobStatusTypeV2
@@ -30,7 +34,7 @@ from fractal_server.zip_tools import _zip_folder_to_byte_stream_iterator
30
34
  router = APIRouter()
31
35
 
32
36
 
33
- @router.get("/", response_model=list[JobReadV2])
37
+ @router.get("/", response_model=PaginationResponse[JobReadV2])
34
38
  async def view_job(
35
39
  id: int | None = None,
36
40
  user_id: int | None = None,
@@ -43,9 +47,10 @@ async def view_job(
43
47
  end_timestamp_min: AwareDatetime | None = None,
44
48
  end_timestamp_max: AwareDatetime | None = None,
45
49
  log: bool = True,
46
- user: UserOAuth = Depends(current_active_superuser),
50
+ pagination: PaginationRequest = Depends(get_pagination_params),
51
+ user: UserOAuth = Depends(current_superuser_act),
47
52
  db: AsyncSession = Depends(get_async_db),
48
- ) -> list[JobReadV2]:
53
+ ) -> PaginationResponse[JobReadV2]:
49
54
  """
50
55
  Query `ApplyWorkflow` table.
51
56
 
@@ -68,50 +73,81 @@ async def view_job(
68
73
  `job.log` is set to `None`.
69
74
  """
70
75
 
71
- stm = select(JobV2)
76
+ # Assign pagination parameters
77
+ page = pagination.page
78
+ page_size = pagination.page_size
72
79
 
80
+ # Prepare statements
81
+ stm = select(JobV2).order_by(JobV2.start_timestamp.desc())
82
+ stm_count = select(func.count(JobV2.id))
73
83
  if id is not None:
74
84
  stm = stm.where(JobV2.id == id)
85
+ stm_count = stm_count.where(JobV2.id == id)
75
86
  if user_id is not None:
76
87
  stm = stm.join(ProjectV2).where(
77
88
  ProjectV2.user_list.any(UserOAuth.id == user_id)
78
89
  )
90
+ stm_count = stm_count.join(ProjectV2).where(
91
+ ProjectV2.user_list.any(UserOAuth.id == user_id)
92
+ )
79
93
  if project_id is not None:
80
94
  stm = stm.where(JobV2.project_id == project_id)
95
+ stm_count = stm_count.where(JobV2.project_id == project_id)
81
96
  if dataset_id is not None:
82
97
  stm = stm.where(JobV2.dataset_id == dataset_id)
98
+ stm_count = stm_count.where(JobV2.dataset_id == dataset_id)
83
99
  if workflow_id is not None:
84
100
  stm = stm.where(JobV2.workflow_id == workflow_id)
101
+ stm_count = stm_count.where(JobV2.workflow_id == workflow_id)
85
102
  if status is not None:
86
103
  stm = stm.where(JobV2.status == status)
104
+ stm_count = stm_count.where(JobV2.status == status)
87
105
  if start_timestamp_min is not None:
88
- start_timestamp_min = start_timestamp_min
89
106
  stm = stm.where(JobV2.start_timestamp >= start_timestamp_min)
107
+ stm_count = stm_count.where(
108
+ JobV2.start_timestamp >= start_timestamp_min
109
+ )
90
110
  if start_timestamp_max is not None:
91
- start_timestamp_max = start_timestamp_max
92
111
  stm = stm.where(JobV2.start_timestamp <= start_timestamp_max)
112
+ stm_count = stm_count.where(
113
+ JobV2.start_timestamp <= start_timestamp_max
114
+ )
93
115
  if end_timestamp_min is not None:
94
- end_timestamp_min = end_timestamp_min
95
116
  stm = stm.where(JobV2.end_timestamp >= end_timestamp_min)
117
+ stm_count = stm_count.where(JobV2.end_timestamp >= end_timestamp_min)
96
118
  if end_timestamp_max is not None:
97
- end_timestamp_max = end_timestamp_max
98
119
  stm = stm.where(JobV2.end_timestamp <= end_timestamp_max)
120
+ stm_count = stm_count.where(JobV2.end_timestamp <= end_timestamp_max)
99
121
 
122
+ # Find total number of elements
123
+ res_total_count = await db.execute(stm_count)
124
+ total_count = res_total_count.scalar()
125
+ if page_size is None:
126
+ page_size = total_count
127
+ else:
128
+ stm = stm.offset((page - 1) * page_size).limit(page_size)
129
+
130
+ # Get `page_size` rows
100
131
  res = await db.execute(stm)
101
132
  job_list = res.scalars().all()
102
- await db.close()
133
+
103
134
  if not log:
104
135
  for job in job_list:
105
136
  setattr(job, "log", None)
106
137
 
107
- return job_list
138
+ return PaginationResponse[JobReadV2](
139
+ total_count=total_count,
140
+ page_size=page_size,
141
+ current_page=page,
142
+ items=[job.model_dump() for job in job_list],
143
+ )
108
144
 
109
145
 
110
146
  @router.get("/{job_id}/", response_model=JobReadV2)
111
147
  async def view_single_job(
112
148
  job_id: int,
113
149
  show_tmp_logs: bool = False,
114
- user: UserOAuth = Depends(current_active_superuser),
150
+ user: UserOAuth = Depends(current_superuser_act),
115
151
  db: AsyncSession = Depends(get_async_db),
116
152
  ) -> JobReadV2:
117
153
  job = await db.get(JobV2, job_id)
@@ -136,7 +172,7 @@ async def view_single_job(
136
172
  async def update_job(
137
173
  job_update: JobUpdateV2,
138
174
  job_id: int,
139
- user: UserOAuth = Depends(current_active_superuser),
175
+ user: UserOAuth = Depends(current_superuser_act),
140
176
  db: AsyncSession = Depends(get_async_db),
141
177
  ) -> JobReadV2 | None:
142
178
  """
@@ -200,7 +236,7 @@ async def update_job(
200
236
  @router.get("/{job_id}/stop/", status_code=202)
201
237
  async def stop_job(
202
238
  job_id: int,
203
- user: UserOAuth = Depends(current_active_superuser),
239
+ user: UserOAuth = Depends(current_superuser_act),
204
240
  db: AsyncSession = Depends(get_async_db),
205
241
  ) -> Response:
206
242
  """
@@ -224,7 +260,7 @@ async def stop_job(
224
260
  @router.get("/{job_id}/download/", response_class=StreamingResponse)
225
261
  async def download_job_logs(
226
262
  job_id: int,
227
- user: UserOAuth = Depends(current_active_superuser),
263
+ user: UserOAuth = Depends(current_superuser_act),
228
264
  db: AsyncSession = Depends(get_async_db),
229
265
  ) -> StreamingResponse:
230
266
  """
@@ -0,0 +1,100 @@
1
+ from fastapi import APIRouter
2
+ from fastapi import Depends
3
+ from fastapi import HTTPException
4
+ from fastapi import Response
5
+ from fastapi import status
6
+ from sqlmodel import func
7
+ from sqlmodel import select
8
+
9
+ from ._aux_functions import _check_profile_name
10
+ from ._aux_functions import _get_profile_or_404
11
+ from fractal_server.app.db import AsyncSession
12
+ from fractal_server.app.db import get_async_db
13
+ from fractal_server.app.models import Profile
14
+ from fractal_server.app.models import UserOAuth
15
+ from fractal_server.app.routes.auth import current_superuser_act
16
+ from fractal_server.app.schemas.v2 import ProfileCreate
17
+ from fractal_server.app.schemas.v2 import ProfileRead
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.get("/{profile_id}/", response_model=ProfileRead, status_code=200)
23
+ async def get_single_profile(
24
+ profile_id: int,
25
+ superuser: UserOAuth = Depends(current_superuser_act),
26
+ db: AsyncSession = Depends(get_async_db),
27
+ ) -> ProfileRead:
28
+ """
29
+ Query single `Profile`.
30
+ """
31
+ profile = await _get_profile_or_404(profile_id=profile_id, db=db)
32
+ return profile
33
+
34
+
35
+ @router.get("/", response_model=list[ProfileRead], status_code=200)
36
+ async def get_profile_list(
37
+ superuser: UserOAuth = Depends(current_superuser_act),
38
+ db: AsyncSession = Depends(get_async_db),
39
+ ) -> ProfileRead:
40
+ """
41
+ Query single `Profile`.
42
+ """
43
+ res = await db.execute(select(Profile).order_by(Profile.id))
44
+ profiles = res.scalars().all()
45
+ return profiles
46
+
47
+
48
+ @router.put("/{profile_id}/", response_model=ProfileRead, status_code=200)
49
+ async def put_profile(
50
+ profile_id: int,
51
+ profile_update: ProfileCreate,
52
+ superuser: UserOAuth = Depends(current_superuser_act),
53
+ db: AsyncSession = Depends(get_async_db),
54
+ ) -> ProfileRead:
55
+ """
56
+ Override single `Profile`.
57
+ """
58
+ profile = await _get_profile_or_404(profile_id=profile_id, db=db)
59
+
60
+ if profile_update.name and profile_update.name != profile.name:
61
+ await _check_profile_name(name=profile_update.name, db=db)
62
+
63
+ for key, value in profile_update.model_dump().items():
64
+ setattr(profile, key, value)
65
+ await db.commit()
66
+ await db.refresh(profile)
67
+ return profile
68
+
69
+
70
+ @router.delete("/{profile_id}/", status_code=204)
71
+ async def delete_profile(
72
+ profile_id: int,
73
+ superuser: UserOAuth = Depends(current_superuser_act),
74
+ db: AsyncSession = Depends(get_async_db),
75
+ ):
76
+ """
77
+ Delete single `Profile`.
78
+ """
79
+ profile = await _get_profile_or_404(profile_id=profile_id, db=db)
80
+
81
+ # Fail if at least one UserOAuth is associated with the Profile.
82
+ res = await db.execute(
83
+ select(func.count(UserOAuth.id)).where(
84
+ UserOAuth.profile_id == profile.id
85
+ )
86
+ )
87
+ associated_users_count = res.scalar()
88
+ if associated_users_count > 0:
89
+ raise HTTPException(
90
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
91
+ detail=(
92
+ f"Cannot delete Profile {profile_id} because it's associated"
93
+ f" with {associated_users_count} UserOAuths."
94
+ ),
95
+ )
96
+
97
+ # Delete
98
+ await db.delete(profile)
99
+ await db.commit()
100
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -6,7 +6,7 @@ from fractal_server.app.db import AsyncSession
6
6
  from fractal_server.app.db import get_async_db
7
7
  from fractal_server.app.models import UserOAuth
8
8
  from fractal_server.app.models.v2 import ProjectV2
9
- from fractal_server.app.routes.auth import current_active_superuser
9
+ from fractal_server.app.routes.auth import current_superuser_act
10
10
  from fractal_server.app.schemas.v2 import ProjectReadV2
11
11
 
12
12
  router = APIRouter()
@@ -16,7 +16,7 @@ router = APIRouter()
16
16
  async def view_project(
17
17
  id: int | None = None,
18
18
  user_id: int | None = None,
19
- user: UserOAuth = Depends(current_active_superuser),
19
+ user: UserOAuth = Depends(current_superuser_act),
20
20
  db: AsyncSession = Depends(get_async_db),
21
21
  ) -> list[ProjectReadV2]:
22
22
  """
@@ -0,0 +1,222 @@
1
+ from fastapi import APIRouter
2
+ from fastapi import Depends
3
+ from fastapi import HTTPException
4
+ from fastapi import Response
5
+ from fastapi import status
6
+ from sqlalchemy.exc import IntegrityError
7
+ from sqlmodel import select
8
+
9
+ from ._aux_functions import _check_resource_name
10
+ from ._aux_functions import _get_resource_or_404
11
+ from .profile import _check_profile_name
12
+ from fractal_server.app.db import AsyncSession
13
+ from fractal_server.app.db import get_async_db
14
+ from fractal_server.app.models import UserOAuth
15
+ from fractal_server.app.models.v2 import Profile
16
+ from fractal_server.app.models.v2 import Resource
17
+ from fractal_server.app.routes.auth import current_superuser_act
18
+ from fractal_server.app.schemas.v2 import ProfileCreate
19
+ from fractal_server.app.schemas.v2 import ProfileRead
20
+ from fractal_server.app.schemas.v2 import ResourceCreate
21
+ from fractal_server.app.schemas.v2 import ResourceRead
22
+ from fractal_server.config import get_settings
23
+ from fractal_server.syringe import Inject
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ def _check_resource_type_match_or_422(
29
+ resource: Resource,
30
+ new_profile: ProfileCreate,
31
+ ) -> None:
32
+ if resource.type != new_profile.resource_type:
33
+ raise HTTPException(
34
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
35
+ detail=(
36
+ f"{resource.type=} differs from {new_profile.resource_type=}."
37
+ ),
38
+ )
39
+
40
+
41
+ def _check_type_match_or_422(new_resource: ResourceCreate) -> None:
42
+ """
43
+ Handle case where `resource.type != FRACTAL_RUNNER_BACKEND`
44
+ """
45
+ settings = Inject(get_settings)
46
+ if settings.FRACTAL_RUNNER_BACKEND != new_resource.type:
47
+ raise HTTPException(
48
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
49
+ detail=(
50
+ f"{settings.FRACTAL_RUNNER_BACKEND=} != "
51
+ f"{new_resource.type=}"
52
+ ),
53
+ )
54
+
55
+
56
+ @router.get("/", response_model=list[ResourceRead], status_code=200)
57
+ async def get_resource_list(
58
+ superuser: UserOAuth = Depends(current_superuser_act),
59
+ db: AsyncSession = Depends(get_async_db),
60
+ ) -> list[ResourceRead]:
61
+ """
62
+ Query `Resource` table.
63
+ """
64
+
65
+ stm = select(Resource).order_by(Resource.id)
66
+ res = await db.execute(stm)
67
+ resource_list = res.scalars().all()
68
+
69
+ return resource_list
70
+
71
+
72
+ @router.get("/{resource_id}/", response_model=ResourceRead, status_code=200)
73
+ async def get_resource(
74
+ resource_id: int,
75
+ superuser: UserOAuth = Depends(current_superuser_act),
76
+ db: AsyncSession = Depends(get_async_db),
77
+ ) -> ResourceRead:
78
+ """
79
+ Query single `Resource`.
80
+ """
81
+ resource = await _get_resource_or_404(resource_id=resource_id, db=db)
82
+
83
+ return resource
84
+
85
+
86
+ @router.post("/", response_model=ResourceRead, status_code=201)
87
+ async def post_resource(
88
+ resource_create: ResourceCreate,
89
+ superuser: UserOAuth = Depends(current_superuser_act),
90
+ db: AsyncSession = Depends(get_async_db),
91
+ ) -> ResourceRead:
92
+ """
93
+ Create new `Resource`.
94
+ """
95
+
96
+ # Handle case where type!=FRACTAL_RUNNER_BACKEND
97
+ _check_type_match_or_422(resource_create)
98
+
99
+ await _check_resource_name(name=resource_create.name, db=db)
100
+
101
+ resource = Resource(**resource_create.model_dump())
102
+ db.add(resource)
103
+ await db.commit()
104
+ await db.refresh(resource)
105
+
106
+ return resource
107
+
108
+
109
+ @router.put(
110
+ "/{resource_id}/",
111
+ response_model=ResourceRead,
112
+ status_code=200,
113
+ )
114
+ async def put_resource(
115
+ resource_id: int,
116
+ resource_update: ResourceCreate,
117
+ superuser: UserOAuth = Depends(current_superuser_act),
118
+ db: AsyncSession = Depends(get_async_db),
119
+ ) -> ResourceRead:
120
+ """
121
+ Overwrite a single `Resource`.
122
+ """
123
+
124
+ # Handle case where type!=FRACTAL_RUNNER_BACKEND
125
+ _check_type_match_or_422(resource_update)
126
+
127
+ resource = await _get_resource_or_404(resource_id=resource_id, db=db)
128
+
129
+ # Handle non-unique resource names
130
+ if resource_update.name and resource_update.name != resource.name:
131
+ await _check_resource_name(name=resource_update.name, db=db)
132
+
133
+ # Prepare new db object
134
+ for key, value in resource_update.model_dump().items():
135
+ setattr(resource, key, value)
136
+
137
+ await db.commit()
138
+ await db.refresh(resource)
139
+ return resource
140
+
141
+
142
+ @router.delete("/{resource_id}/", status_code=204)
143
+ async def delete_resource(
144
+ resource_id: int,
145
+ superuser: UserOAuth = Depends(current_superuser_act),
146
+ db: AsyncSession = Depends(get_async_db),
147
+ ):
148
+ """
149
+ Delete single `Resource`.
150
+ """
151
+ resource = await _get_resource_or_404(resource_id=resource_id, db=db)
152
+ try:
153
+ await db.delete(resource)
154
+ await db.commit()
155
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
156
+ except IntegrityError as e:
157
+ await db.rollback()
158
+ raise HTTPException(
159
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
160
+ detail=(
161
+ "IntegrityError for resource deletion. "
162
+ f"Original error:\n{str(e)}"
163
+ ),
164
+ )
165
+
166
+
167
+ @router.get(
168
+ "/{resource_id}/profile/",
169
+ response_model=list[ProfileRead],
170
+ status_code=200,
171
+ )
172
+ async def get_resource_profiles(
173
+ resource_id: int,
174
+ superuser: UserOAuth = Depends(current_superuser_act),
175
+ db: AsyncSession = Depends(get_async_db),
176
+ ) -> list[ProfileRead]:
177
+ """
178
+ Query `Profile`s for single `Resource`.
179
+ """
180
+ await _get_resource_or_404(resource_id=resource_id, db=db)
181
+
182
+ res = await db.execute(
183
+ select(Profile)
184
+ .where(Profile.resource_id == resource_id)
185
+ .order_by(Profile.id)
186
+ )
187
+ profiles = res.scalars().all()
188
+
189
+ return profiles
190
+
191
+
192
+ @router.post(
193
+ "/{resource_id}/profile/",
194
+ response_model=ProfileRead,
195
+ status_code=201,
196
+ )
197
+ async def post_profile(
198
+ resource_id: int,
199
+ profile_create: ProfileCreate,
200
+ superuser: UserOAuth = Depends(current_superuser_act),
201
+ db: AsyncSession = Depends(get_async_db),
202
+ ) -> ProfileRead:
203
+ """
204
+ Create new `Profile`.
205
+ """
206
+ resource = await _get_resource_or_404(resource_id=resource_id, db=db)
207
+
208
+ _check_resource_type_match_or_422(
209
+ resource=resource,
210
+ new_profile=profile_create,
211
+ )
212
+ await _check_profile_name(name=profile_create.name, db=db)
213
+
214
+ profile = Profile(
215
+ resource_id=resource_id,
216
+ **profile_create.model_dump(),
217
+ )
218
+
219
+ db.add(profile)
220
+ await db.commit()
221
+ await db.refresh(profile)
222
+ return profile