aind-data-transfer-service 1.15.0__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of aind-data-transfer-service might be problematic. Click here for more details.

@@ -1,7 +1,7 @@
1
1
  """Init package"""
2
2
  import os
3
3
 
4
- __version__ = "1.15.0"
4
+ __version__ = "1.17.0"
5
5
 
6
6
  # Global constants
7
7
  OPEN_DATA_BUCKET_NAME = os.getenv("OPEN_DATA_BUCKET_NAME", "open")
@@ -1,7 +1,10 @@
1
1
  """Module to handle processing legacy csv files"""
2
2
 
3
3
  import re
4
+ from collections.abc import Mapping
5
+ from copy import deepcopy
4
6
  from datetime import datetime
7
+ from typing import Any, Dict
5
8
 
6
9
  from aind_data_schema_models.modalities import Modality
7
10
  from aind_data_schema_models.platforms import Platform
@@ -13,6 +16,45 @@ DATETIME_PATTERN2 = re.compile(
13
16
  )
14
17
 
15
18
 
19
+ def nested_update(dict_to_update: Dict[str, Any], updates: Mapping):
20
+ """
21
+ Update a nested dictionary in-place.
22
+ Parameters
23
+ ----------
24
+ dict_to_update : Dict[str, Any]
25
+ updates : Mapping
26
+
27
+ """
28
+ for k, v in updates.items():
29
+ if isinstance(v, Mapping):
30
+ dict_to_update[k] = nested_update(dict_to_update.get(k, {}), v)
31
+ else:
32
+ dict_to_update[k] = v
33
+ return dict_to_update
34
+
35
+
36
+ def create_nested_dict(
37
+ dict_to_update: Dict[str, Any], key_string: str, value: Any
38
+ ):
39
+ """
40
+ Updates in-place a nested dictionary with a period delimited key and value.
41
+ Parameters
42
+ ----------
43
+ dict_to_update : Dict[str, Any]
44
+ key_string : str
45
+ value : Any
46
+
47
+ """
48
+ keys = key_string.split(".", 1)
49
+ current_key = keys[0]
50
+ if len(keys) == 1:
51
+ dict_to_update[current_key] = value
52
+ else:
53
+ if current_key not in dict_to_update:
54
+ dict_to_update[current_key] = dict()
55
+ create_nested_dict(dict_to_update[current_key], keys[1], value)
56
+
57
+
16
58
  def map_csv_row_to_job(row: dict) -> UploadJobConfigsV2:
17
59
  """
18
60
  Maps csv row into a UploadJobConfigsV2 model. This attempts to be somewhat
@@ -29,7 +71,6 @@ def map_csv_row_to_job(row: dict) -> UploadJobConfigsV2:
29
71
  modality_configs = dict()
30
72
  job_configs = dict()
31
73
  check_s3_folder_exists_task = None
32
- final_check_s3_folder_exist = None
33
74
  codeocean_tasks = dict()
34
75
  for key, value in row.items():
35
76
  # Strip white spaces and replace dashes with underscores
@@ -42,7 +83,9 @@ def map_csv_row_to_job(row: dict) -> UploadJobConfigsV2:
42
83
  modality_parts = clean_key.split(".")
43
84
  modality_key = modality_parts[0]
44
85
  sub_key = (
45
- "modality" if len(modality_parts) == 1 else modality_parts[1]
86
+ "modality"
87
+ if len(modality_parts) == 1
88
+ else ".".join(modality_parts[1:])
46
89
  )
47
90
  modality_configs.setdefault(modality_key, dict())
48
91
  # Temp backwards compatibility check
@@ -66,13 +109,22 @@ def map_csv_row_to_job(row: dict) -> UploadJobConfigsV2:
66
109
  job_settings=codeocean_pipeline_monitor_settings,
67
110
  )
68
111
  else:
69
- modality_configs[modality_key].update({sub_key: clean_val})
112
+ nested_val = dict()
113
+ create_nested_dict(
114
+ dict_to_update=nested_val,
115
+ key_string=sub_key,
116
+ value=clean_val,
117
+ )
118
+ current_dict = deepcopy(
119
+ modality_configs.get(modality_key, dict())
120
+ )
121
+ nested_update(current_dict, nested_val)
122
+ modality_configs[modality_key] = current_dict
70
123
  elif clean_key == "force_cloud_sync" and clean_val.upper() in [
71
124
  "TRUE",
72
125
  "T",
73
126
  ]:
74
127
  check_s3_folder_exists_task = {"skip_task": True}
75
- final_check_s3_folder_exist = {"skip_task": True}
76
128
  else:
77
129
  job_configs[clean_key] = clean_val
78
130
  # Rename codeocean config keys with correct modality
@@ -93,8 +145,7 @@ def map_csv_row_to_job(row: dict) -> UploadJobConfigsV2:
93
145
  )
94
146
  tasks = {
95
147
  "gather_preliminary_metadata": metadata_task,
96
- "check_s3_folder_exists_task": check_s3_folder_exists_task,
97
- "final_check_s3_folder_exist": final_check_s3_folder_exist,
148
+ "check_s3_folder_exists": check_s3_folder_exists_task,
98
149
  "modality_transformation_settings": modality_tasks,
99
150
  "codeocean_pipeline_settings": None
100
151
  if codeocean_tasks == dict()
@@ -3,8 +3,9 @@
3
3
  import ast
4
4
  import os
5
5
  from datetime import datetime, timedelta, timezone
6
- from typing import List, Optional, Union
6
+ from typing import ClassVar, List, Optional, Union
7
7
 
8
+ from aind_data_schema_models.modalities import Modality
8
9
  from mypy_boto3_ssm.type_defs import ParameterMetadataTypeDef
9
10
  from pydantic import AwareDatetime, BaseModel, Field, field_validator
10
11
  from starlette.datastructures import QueryParams
@@ -223,11 +224,30 @@ class JobTasks(BaseModel):
223
224
  class JobParamInfo(BaseModel):
224
225
  """Model for job parameter info from AWS Parameter Store"""
225
226
 
227
+ _MODALITIES_LIST: ClassVar[list[str]] = list(
228
+ Modality.abbreviation_map.keys()
229
+ )
230
+ _MODALITY_TASKS: ClassVar[list[str]] = [
231
+ "modality_transformation_settings",
232
+ "codeocean_pipeline_settings",
233
+ ]
234
+
226
235
  name: Optional[str]
227
236
  last_modified: Optional[datetime]
228
- job_type: str
229
- task_id: str
237
+ job_type: str = Field(..., pattern=r"^[^\s/]+$")
238
+ task_id: str = Field(..., pattern=r"^[^\s/]+$")
230
239
  modality: Optional[str]
240
+ version: Optional[str] = Field(..., pattern=r"^(v1|v2)?$")
241
+
242
+ @field_validator("modality", mode="after")
243
+ def validate_modality(cls, v):
244
+ """Check that modality is one of aind-data-schema modalities"""
245
+ if v is not None and v not in JobParamInfo._MODALITIES_LIST:
246
+ raise ValueError(
247
+ "Invalid modality: modality must be one of "
248
+ f"{JobParamInfo._MODALITIES_LIST}"
249
+ )
250
+ return v
231
251
 
232
252
  @classmethod
233
253
  def from_aws_describe_parameter(
@@ -236,6 +256,7 @@ class JobParamInfo(BaseModel):
236
256
  job_type: str,
237
257
  task_id: str,
238
258
  modality: Optional[str],
259
+ version: Optional[str],
239
260
  ):
240
261
  """Map the parameter to the model"""
241
262
  return cls(
@@ -244,13 +265,14 @@ class JobParamInfo(BaseModel):
244
265
  job_type=job_type,
245
266
  task_id=task_id,
246
267
  modality=modality,
268
+ version=version,
247
269
  )
248
270
 
249
271
  @staticmethod
250
272
  def get_parameter_prefix(version: Optional[str] = None) -> str:
251
273
  """Get the prefix for job_type parameters"""
252
274
  prefix = os.getenv("AIND_AIRFLOW_PARAM_PREFIX")
253
- if version is None:
275
+ if version is None or version == "v1":
254
276
  return prefix
255
277
  return f"{prefix}/{version}"
256
278
 
@@ -262,16 +284,19 @@ class JobParamInfo(BaseModel):
262
284
  "(?P<job_type>[^/]+)/tasks/(?P<task_id>[^/]+)"
263
285
  "(?:/(?P<modality>[^/]+))?"
264
286
  )
265
- if version is None:
287
+ if version is None or version == "v1":
266
288
  return f"{prefix}/{regex}"
267
289
  return f"{prefix}/{version}/{regex}"
268
290
 
269
291
  @staticmethod
270
292
  def get_parameter_name(
271
- job_type: str, task_id: str, version: Optional[str] = None
293
+ job_type: str,
294
+ task_id: str,
295
+ modality: Optional[str],
296
+ version: Optional[str] = None,
272
297
  ) -> str:
273
298
  """Create the parameter name from job_type and task_id"""
274
- prefix = os.getenv("AIND_AIRFLOW_PARAM_PREFIX")
275
- if version is None:
276
- return f"{prefix}/{job_type}/tasks/{task_id}"
277
- return f"{prefix}/{version}/{job_type}/tasks/{task_id}"
299
+ prefix = JobParamInfo.get_parameter_prefix(version)
300
+ if modality:
301
+ return f"{prefix}/{job_type}/tasks/{task_id}/{modality}"
302
+ return f"{prefix}/{job_type}/tasks/{task_id}"
@@ -7,7 +7,7 @@ import os
7
7
  import re
8
8
  from asyncio import gather, sleep
9
9
  from pathlib import PurePosixPath
10
- from typing import List, Optional, Union
10
+ from typing import Any, List, Optional, Union
11
11
 
12
12
  import boto3
13
13
  import requests
@@ -29,7 +29,9 @@ from starlette.middleware.sessions import SessionMiddleware
29
29
  from starlette.responses import RedirectResponse
30
30
  from starlette.routing import Route
31
31
 
32
- from aind_data_transfer_service import OPEN_DATA_BUCKET_NAME
32
+ from aind_data_transfer_service import (
33
+ OPEN_DATA_BUCKET_NAME,
34
+ )
33
35
  from aind_data_transfer_service import (
34
36
  __version__ as aind_data_transfer_service_version,
35
37
  )
@@ -37,14 +39,18 @@ from aind_data_transfer_service.configs.csv_handler import map_csv_row_to_job
37
39
  from aind_data_transfer_service.configs.job_configs import (
38
40
  BasicUploadJobConfigs as LegacyBasicUploadJobConfigs,
39
41
  )
40
- from aind_data_transfer_service.configs.job_configs import HpcJobConfigs
42
+ from aind_data_transfer_service.configs.job_configs import (
43
+ HpcJobConfigs,
44
+ )
41
45
  from aind_data_transfer_service.configs.job_upload_template import (
42
46
  JobUploadTemplate,
43
47
  )
44
48
  from aind_data_transfer_service.hpc.client import HpcClient, HpcClientConfigs
45
49
  from aind_data_transfer_service.hpc.models import HpcJobSubmitSettings
46
50
  from aind_data_transfer_service.log_handler import LoggingConfigs, get_logger
47
- from aind_data_transfer_service.models.core import SubmitJobRequestV2
51
+ from aind_data_transfer_service.models.core import (
52
+ SubmitJobRequestV2,
53
+ )
48
54
  from aind_data_transfer_service.models.core import (
49
55
  validation_context as validation_context_v2,
50
56
  )
@@ -150,6 +156,7 @@ def get_parameter_infos(version: Optional[str] = None) -> List[JobParamInfo]:
150
156
  job_type=match.group("job_type"),
151
157
  task_id=match.group("task_id"),
152
158
  modality=match.group("modality"),
159
+ version=version,
153
160
  )
154
161
  params.append(param_info)
155
162
  else:
@@ -167,6 +174,19 @@ def get_parameter_value(param_name: str) -> dict:
167
174
  return param_value
168
175
 
169
176
 
177
+ def put_parameter_value(param_name: str, param_value: dict) -> Any:
178
+ """Set a parameter value in AWS param store based on parameter name"""
179
+ param_value_str = json.dumps(param_value)
180
+ ssm_client = boto3.client("ssm")
181
+ result = ssm_client.put_parameter(
182
+ Name=param_name,
183
+ Value=param_value_str,
184
+ Type="String",
185
+ Overwrite=True,
186
+ )
187
+ return result
188
+
189
+
170
190
  async def get_airflow_jobs(
171
191
  params: AirflowDagRunsRequestParameters, get_confs: bool = False
172
192
  ) -> tuple[int, Union[List[JobStatus], List[dict]]]:
@@ -929,10 +949,10 @@ async def get_task_logs(request: Request):
929
949
  async def index(request: Request):
930
950
  """GET|POST /: form handler"""
931
951
  return templates.TemplateResponse(
952
+ request=request,
932
953
  name="index.html",
933
954
  context=(
934
955
  {
935
- "request": request,
936
956
  "project_names_url": project_names_url,
937
957
  }
938
958
  ),
@@ -945,10 +965,10 @@ async def job_tasks_table(request: Request):
945
965
  response_tasks_json = json.loads(response_tasks.body)
946
966
  data = response_tasks_json.get("data")
947
967
  return templates.TemplateResponse(
968
+ request=request,
948
969
  name="job_tasks_table.html",
949
970
  context=(
950
971
  {
951
- "request": request,
952
972
  "status_code": response_tasks.status_code,
953
973
  "message": response_tasks_json.get("message"),
954
974
  "errors": data.get("errors", []),
@@ -965,10 +985,10 @@ async def task_logs(request: Request):
965
985
  response_tasks_json = json.loads(response_tasks.body)
966
986
  data = response_tasks_json.get("data")
967
987
  return templates.TemplateResponse(
988
+ request=request,
968
989
  name="task_logs.html",
969
990
  context=(
970
991
  {
971
- "request": request,
972
992
  "status_code": response_tasks.status_code,
973
993
  "message": response_tasks_json.get("message"),
974
994
  "errors": data.get("errors", []),
@@ -982,10 +1002,10 @@ async def jobs(request: Request):
982
1002
  """Get Job Status page with pagination"""
983
1003
  dag_ids = AirflowDagRunsRequestParameters.model_fields["dag_ids"].default
984
1004
  return templates.TemplateResponse(
1005
+ request=request,
985
1006
  name="job_status.html",
986
1007
  context=(
987
1008
  {
988
- "request": request,
989
1009
  "project_names_url": project_names_url,
990
1010
  "dag_ids": dag_ids,
991
1011
  }
@@ -995,16 +1015,20 @@ async def jobs(request: Request):
995
1015
 
996
1016
  async def job_params(request: Request):
997
1017
  """Get Job Parameters page"""
1018
+ user = request.session.get("user")
998
1019
  return templates.TemplateResponse(
1020
+ request=request,
999
1021
  name="job_params.html",
1000
1022
  context=(
1001
1023
  {
1002
- "request": request,
1024
+ "user_signed_in": user is not None,
1003
1025
  "project_names_url": os.getenv(
1004
1026
  "AIND_METADATA_SERVICE_PROJECT_NAMES_URL"
1005
1027
  ),
1006
1028
  "versions": ["v1", "v2"],
1007
1029
  "default_version": "v1",
1030
+ "modalities": JobParamInfo._MODALITIES_LIST,
1031
+ "modality_tasks": JobParamInfo._MODALITY_TASKS,
1008
1032
  }
1009
1033
  ),
1010
1034
  )
@@ -1068,7 +1092,10 @@ def get_parameter_v2(request: Request):
1068
1092
  # path params are auto validated
1069
1093
  job_type = request.path_params.get("job_type")
1070
1094
  task_id = request.path_params.get("task_id")
1071
- param_name = JobParamInfo.get_parameter_name(job_type, task_id, "v2")
1095
+ modality = request.path_params.get("modality")
1096
+ param_name = JobParamInfo.get_parameter_name(
1097
+ job_type=job_type, task_id=task_id, modality=modality, version="v2"
1098
+ )
1072
1099
  try:
1073
1100
  param_value = get_parameter_value(param_name)
1074
1101
  return JSONResponse(
@@ -1089,12 +1116,79 @@ def get_parameter_v2(request: Request):
1089
1116
  )
1090
1117
 
1091
1118
 
1119
+ async def put_parameter(request: Request):
1120
+ """Set v1/v2 parameter in AWS param store based on job_type and task_id"""
1121
+ # User must be signed in
1122
+ user = request.session.get("user")
1123
+ if not user:
1124
+ return JSONResponse(
1125
+ content={
1126
+ "message": "User not authenticated",
1127
+ "data": {"error": "User not authenticated"},
1128
+ },
1129
+ status_code=401,
1130
+ )
1131
+ try:
1132
+ # path params
1133
+ param_info = JobParamInfo(
1134
+ name=None,
1135
+ last_modified=None,
1136
+ job_type=request.path_params.get("job_type"),
1137
+ task_id=request.path_params.get("task_id"),
1138
+ modality=request.path_params.get("modality"),
1139
+ version=request.path_params.get("version"),
1140
+ )
1141
+ param_name = JobParamInfo.get_parameter_name(
1142
+ job_type=param_info.job_type,
1143
+ task_id=param_info.task_id,
1144
+ modality=param_info.modality,
1145
+ version=param_info.version,
1146
+ )
1147
+ # update param store
1148
+ logger.info(
1149
+ f"Received request from {user} to set parameter {param_name}"
1150
+ )
1151
+ param_value = await request.json()
1152
+ logger.info(f"Setting parameter {param_name} to {param_value}")
1153
+ result = put_parameter_value(
1154
+ param_name=param_name, param_value=param_value
1155
+ )
1156
+ logger.info(result)
1157
+ return JSONResponse(
1158
+ content={
1159
+ "message": f"Set parameter for {param_name}",
1160
+ "data": param_value,
1161
+ },
1162
+ status_code=200,
1163
+ )
1164
+ except ValidationError as error:
1165
+ return JSONResponse(
1166
+ content={
1167
+ "message": "Invalid parameter",
1168
+ "data": {"errors": json.loads(error.json())},
1169
+ },
1170
+ status_code=400,
1171
+ )
1172
+ except Exception as e:
1173
+ logger.exception(f"Error setting parameter {param_name}: {e}")
1174
+ return JSONResponse(
1175
+ content={
1176
+ "message": f"Error setting parameter {param_name}",
1177
+ "data": {"error": f"{e.__class__.__name__}{e.args}"},
1178
+ },
1179
+ status_code=500,
1180
+ )
1181
+
1182
+
1092
1183
  def get_parameter(request: Request):
1093
1184
  """Get parameter from AWS parameter store based on job_type and task_id"""
1094
1185
  # path params are auto validated
1095
1186
  job_type = request.path_params.get("job_type")
1096
1187
  task_id = request.path_params.get("task_id")
1097
- param_name = JobParamInfo.get_parameter_name(job_type, task_id)
1188
+ modality = request.path_params.get("modality")
1189
+ param_name = JobParamInfo.get_parameter_name(
1190
+ job_type=job_type, task_id=task_id, modality=modality
1191
+ )
1098
1192
  try:
1099
1193
  param_value = get_parameter_value(param_name)
1100
1194
  return JSONResponse(
@@ -1118,14 +1212,12 @@ def get_parameter(request: Request):
1118
1212
  async def admin(request: Request):
1119
1213
  """Get admin page if authenticated, else redirect to login."""
1120
1214
  user = request.session.get("user")
1121
- if os.getenv("ENV_NAME") == "local":
1122
- user = {"name": "local user"}
1123
1215
  if user:
1124
1216
  return templates.TemplateResponse(
1217
+ request=request,
1125
1218
  name="admin.html",
1126
1219
  context=(
1127
1220
  {
1128
- "request": request,
1129
1221
  "project_names_url": project_names_url,
1130
1222
  "user_name": user.get("name", "unknown"),
1131
1223
  "user_email": user.get("email", "unknown"),
@@ -1137,6 +1229,9 @@ async def admin(request: Request):
1137
1229
 
1138
1230
  async def login(request: Request):
1139
1231
  """Redirect to Azure login page"""
1232
+ if os.getenv("ENV_NAME") == "local":
1233
+ request.session["user"] = {"name": "local user"}
1234
+ return RedirectResponse(url="/admin")
1140
1235
  oauth = set_oauth()
1141
1236
  redirect_uri = request.url_for("auth")
1142
1237
  response = await oauth.azure.authorize_redirect(request, redirect_uri)
@@ -1187,7 +1282,13 @@ routes = [
1187
1282
  Route("/api/v1/get_task_logs", endpoint=get_task_logs, methods=["GET"]),
1188
1283
  Route("/api/v1/parameters", endpoint=list_parameters, methods=["GET"]),
1189
1284
  Route(
1190
- "/api/v1/parameters/job_types/{job_type:str}/tasks/{task_id:path}",
1285
+ "/api/v1/parameters/job_types/{job_type:str}/tasks/{task_id:str}",
1286
+ endpoint=get_parameter,
1287
+ methods=["GET"],
1288
+ ),
1289
+ Route(
1290
+ "/api/v1/parameters/job_types/{job_type:str}/tasks/{task_id:str}"
1291
+ "/{modality:str}",
1191
1292
  endpoint=get_parameter,
1192
1293
  methods=["GET"],
1193
1294
  ),
@@ -1198,10 +1299,28 @@ routes = [
1198
1299
  Route("/api/v2/submit_jobs", endpoint=submit_jobs_v2, methods=["POST"]),
1199
1300
  Route("/api/v2/parameters", endpoint=list_parameters_v2, methods=["GET"]),
1200
1301
  Route(
1201
- "/api/v2/parameters/job_types/{job_type:str}/tasks/{task_id:path}",
1302
+ "/api/v2/parameters/job_types/{job_type:str}/tasks/{task_id:str}",
1303
+ endpoint=get_parameter_v2,
1304
+ methods=["GET"],
1305
+ ),
1306
+ Route(
1307
+ "/api/v2/parameters/job_types/{job_type:str}/tasks/{task_id:str}"
1308
+ "/{modality:str}",
1202
1309
  endpoint=get_parameter_v2,
1203
1310
  methods=["GET"],
1204
1311
  ),
1312
+ Route(
1313
+ "/api/{version:str}/parameters/job_types/{job_type:str}"
1314
+ "/tasks/{task_id:str}",
1315
+ endpoint=put_parameter,
1316
+ methods=["PUT"],
1317
+ ),
1318
+ Route(
1319
+ "/api/{version:str}/parameters/job_types/{job_type:str}"
1320
+ "/tasks/{task_id:str}/{modality:str}",
1321
+ endpoint=put_parameter,
1322
+ methods=["PUT"],
1323
+ ),
1205
1324
  Route("/jobs", endpoint=jobs, methods=["GET"]),
1206
1325
  Route("/job_tasks_table", endpoint=job_tasks_table, methods=["GET"]),
1207
1326
  Route("/task_logs", endpoint=task_logs, methods=["GET"]),
@@ -24,13 +24,22 @@
24
24
  <a title="List of project names" href="{{ project_names_url }}" target="_blank">Project Names</a> |
25
25
  <a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io"
26
26
  target="_blank">Help</a> |
27
- <a href="/admin">Admin</a> |
28
- <a href="/logout">Log out</a>
27
+ <a href="/admin">Admin</a>
28
+ <a href="/logout" class="float-end">Log out</a>
29
29
  </nav>
30
30
  <div>
31
31
  <h3>Admin</h3>
32
32
  <div>Hello {{user_name}}, welcome to the admin page</div>
33
33
  <div>Email: {{user_email}}</div>
34
+ <h4 class="mt-4">Job Type Parameters</h4>
35
+ <div class="mb-2">
36
+ <div class="alert alert-info mb-0 p-4" role="alert">
37
+ To manage job type parameters, go to the <a href="/job_params" class="alert-link">Job Parameters</a> page.
38
+ Select an existing parameter, or click the
39
+ <button type="button" class="btn btn-success btn-sm" disabled><i class="bi bi-plus-circle"></i> Add New Parameter</button>
40
+ button.
41
+ </div>
42
+ </div>
34
43
  </div>
35
44
  </body>
36
45
  </html>
@@ -36,6 +36,11 @@
36
36
  <a title="List of project names" href= "{{ project_names_url }}" target="_blank" >Project Names</a> |
37
37
  <a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a> |
38
38
  <a href="/admin">Admin</a>
39
+ {% if user_signed_in %}
40
+ <a href="/logout" class="float-end">Log out</a>
41
+ {% else %}
42
+ <a href="/login" class="float-end">Log in</a>
43
+ {% endif %}
39
44
  </nav>
40
45
  <div class="content">
41
46
  <h4 class="mb-2">
@@ -52,6 +57,14 @@
52
57
  </div>
53
58
  <span>Job Parameters</span>
54
59
  </h4>
60
+ <!-- button and modal for adding new parameters-->
61
+ {% if user_signed_in %}
62
+ <div class="mb-2">
63
+ <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#param-modal" data-bs-action="new">
64
+ <i class="bi bi-plus-circle"></i> Add New Parameter
65
+ </button>
66
+ </div>
67
+ {% endif %}
55
68
  <!-- job params table -->
56
69
  <div>
57
70
  <table id="job-params-table" class="display compact table table-bordered table-sm" style="font-size: small">
@@ -66,59 +79,128 @@
66
79
  </thead>
67
80
  </table>
68
81
  <!-- modal for displaying param value as json -->
82
+ <!-- if user is signed in, the textarea will be editable and the footer will display the Submit button -->
69
83
  <div class="modal fade" id="param-modal" tabindex="-1" aria-labelledby="param-modal-label" aria-hidden="true">
70
84
  <div class="modal-dialog modal-xl">
71
85
  <div class="modal-content">
72
86
  <div class="modal-header">
73
87
  <h5 class="modal-title" id="param-modal-label"></h5>
88
+ <span class="badge bg-primary ms-2" id="param-modal-version"></span>
74
89
  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
75
90
  </div>
76
91
  <div class="modal-body">
77
- <pre id="param-modal-content" class="bg-light p-1 border rounded"></pre>
92
+ <!-- dropdowns for Add New Parameter -->
93
+ <div class="row mb-2" id="param-modal-dropdowns" style="display:none;">
94
+ <div class="col-md-4">
95
+ <label for="param-modal-job-type-select" class="form-label">Job Type</label>
96
+ <select id="param-modal-job-type-select" class="form-select form-select-sm mb-1"></select>
97
+ <input type="text" id="param-modal-job-type-input" class="form-control form-control-sm mt-1" placeholder="Enter new Job Type" style="display:none;" />
98
+ </div>
99
+ <div class="col-md-4">
100
+ <label for="param-modal-task-id-select" class="form-label">Task ID</label>
101
+ <select id="param-modal-task-id-select" class="form-select form-select-sm"></select>
102
+ <input type="text" id="param-modal-task-id-input" class="form-control form-control-sm mt-1" placeholder="Enter new Task ID" style="display:none;" />
103
+ </div>
104
+ <div class="col-md-4" style="display:none;">
105
+ <label for="param-modal-modality-select" class="form-label">Modality</label>
106
+ <select id="param-modal-modality-select" class="form-select form-select-sm">
107
+ <option value="">Select Modality</option>
108
+ {% for modality in modalities %}
109
+ <option value="{{ modality }}">{{ modality }}</option>
110
+ {% endfor %}
111
+ </select>
112
+ </div>
113
+ </div>
114
+ <!-- textarea for parameter value -->
115
+ <textarea
116
+ id="param-modal-content" class="bg-light form-control form-control-sm font-monospace" rows="15"
117
+ placeholder='{"skip_task": false}' {% if not user_signed_in %}readonly{% endif %}
118
+ ></textarea>
119
+ <!-- message if parameter already exists -->
120
+ <div id="param-modal-param-exists-alert" class="alert alert-info" role="alert" style="display:none;">
121
+ This parameter already exists! To edit, please click on the parameter from the Job Parameters table.
122
+ </div>
123
+ </div>
124
+ {% if user_signed_in %}
125
+ <div class="modal-footer">
126
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
127
+ <button type="button" class="btn btn-secondary" id="param-modal-reset-btn">Reset</button>
128
+ <button type="button" class="btn btn-primary" id="param-modal-submit-btn">Submit</button>
78
129
  </div>
130
+ {% endif %}
79
131
  </div>
80
132
  </div>
81
133
  </div>
82
134
  </div>
83
135
  </div>
84
136
  <script>
137
+ const MODALITY_TASKS = {{ modality_tasks | tojson }}; // from Jinja context
138
+ const MODAL_ID = 'param-modal';
85
139
  $(document).ready(function() {
86
140
  createJobParamsTable();
87
- // Event listeners for param value modal
88
- $('#param-modal').on('show.bs.modal', function(event) {
89
- var button = $(event.relatedTarget);
90
- var paramName = button.data('bs-param-name');
91
- var jobType = button.data('bs-job-type');
92
- var taskId = button.data('bs-task-id');
93
- var modality = button.data('bs-modality');
94
- // update the modal label and contents
95
- var modal = $(this);
96
- modal.find('#param-modal-label').text(paramName);
97
- var version = $('#version-button').text().trim();
98
- var getParameterUrl = `/api/${version}/parameters/job_types/${jobType}/tasks/${taskId}`;
99
- if (modality) {
100
- getParameterUrl += `/${modality}`;
141
+ // Event listeners for modal to display/edit params
142
+ $(`#${MODAL_ID}`).on('show.bs.modal', function(event) {
143
+ // Add New Parameter: set label and dropdown options
144
+ // Edit Existing Parameter: set label and load intital value
145
+ const eventTarget = $(event.relatedTarget);
146
+ const action = eventTarget.data('bs-action');
147
+ const modal = $(`#${MODAL_ID}`);
148
+ modal.data('bs-action', action); // save action type
149
+ const isNew = action === 'new';
150
+ modal.find(`#${MODAL_ID}-label`).text(isNew ? 'Add New Parameter' : eventTarget.data('bs-param-name'));
151
+ modal.find(`#${MODAL_ID}-version`).text(getCurrentVersion()).toggle(isNew);
152
+ modal.find(`#${MODAL_ID}-dropdowns`).toggle(isNew);
153
+ if (isNew) {
154
+ ['Job Type', 'Task ID'].forEach(field => {
155
+ const select = modal.find(`#${MODAL_ID}-${field.toLowerCase().replace(' ', '-')}-select`);
156
+ select.empty()
157
+ .append(`<option value="">Select ${field}</option>`)
158
+ .append(getUniqueColumnValues(field).map(val => `<option value="${val}">${val}</option>`))
159
+ .append(`<option value="__new__">Create new ${field}</option>`);
160
+ });
161
+ } else {
162
+ loadParameterValue(getParamUrlFromParamName(eventTarget.data('bs-param-name')));
101
163
  }
102
- $.ajax({
103
- url: getParameterUrl,
104
- type: 'GET',
105
- success: function(response) {
106
- jsonStr = JSON.stringify(response.data, null, 3);
107
- modal.find('#param-modal-content').text(jsonStr);
108
- },
109
- error: function(xhr, status, error) {
110
- modal.find('#param-modal-content').text(error);
111
- }
112
- });
113
164
  });
114
- $('#param-modal').on('hidden.bs.modal', function() {
115
- $(this).find('#param-modal-label').text('');
116
- $(this).find('#param-modal-content').text('');
165
+ $(`#${MODAL_ID}`).on('hidden.bs.modal', function() {
166
+ onResetModal();
167
+ });
168
+ $(`#${MODAL_ID}`).on('click', `#${MODAL_ID}-reset-btn`, function() {
169
+ onResetModal(true);
170
+ });
171
+ $(`#${MODAL_ID}`).on('click', `#${MODAL_ID}-submit-btn`, function() {
172
+ const modal = $(`#${MODAL_ID}`);
173
+ const action = modal.data('bs-action');
174
+ let url;
175
+ if (action === "new") {
176
+ const version = getCurrentVersion();
177
+ const jobType = getValidatedInputValue('Job Type', modal);
178
+ const taskId = getValidatedInputValue('Task ID', modal);
179
+ const modality = MODALITY_TASKS.includes(taskId) ? getValidatedInputValue('Modality', modal) : null;
180
+ url = getParamUrlFromParamInfo(version, jobType, taskId, modality);
181
+ } else {
182
+ const paramName = modal.find(`#${MODAL_ID}-label`).text();
183
+ url = getParamUrlFromParamName(paramName);
184
+ }
185
+ const paramValue = modal.find(`#${MODAL_ID}-content`).val();
186
+ submitParameterValue(url, paramValue);
187
+ });
188
+ $(`#${MODAL_ID}-job-type-select`).on('change', function() {
189
+ $(`#${MODAL_ID}-job-type-input`).toggle($(this).val() === '__new__').focus();
190
+ handleExistingParam();
191
+ });
192
+ $(`#${MODAL_ID}-task-id-select`).on('change', function() {
193
+ $(`#${MODAL_ID}-task-id-input`).toggle($(this).val() === '__new__').focus();
194
+ $(`#${MODAL_ID}-modality-select`).val('').parent().toggle(MODALITY_TASKS.includes($(this).val()));
195
+ handleExistingParam();
196
+ });
197
+ $(`#${MODAL_ID}-modality-select`).on('change', function() {
198
+ handleExistingParam();
117
199
  });
118
200
  // Event listener for version dropdown
119
201
  $('#version-dropdown .dropdown-item').on('click', function() {
120
202
  var version = $(this).text();
121
- if (version != $('#version-button').text().trim()) {
203
+ if (version != getCurrentVersion()) {
122
204
  $('#version-button').text(version);
123
205
  $('#job-params-table').DataTable().ajax.url(`/api/${version}/parameters`).load();
124
206
  }
@@ -179,7 +261,8 @@
179
261
  return (
180
262
  `<button type="button" class="btn btn-link btn-sm"
181
263
  data-bs-toggle="modal"
182
- data-bs-target="#param-modal"
264
+ data-bs-target="#${MODAL_ID}"
265
+ data-bs-action="edit"
183
266
  data-bs-param-name=${data}
184
267
  data-bs-job-type=${row.job_type}
185
268
  data-bs-task-id=${row.task_id}
@@ -190,6 +273,133 @@
190
273
  }
191
274
  return data;
192
275
  }
276
+ // Methods to load/submit param values in the modal
277
+ function getParamUrlFromParamInfo(version, jobType, taskId, modality) {
278
+ var baseUrl = `/api/${version}/parameters/job_types/${jobType}/tasks/${taskId}`;
279
+ return modality ? `${baseUrl}/${modality}` : baseUrl;
280
+ }
281
+ function getParamUrlFromParamName(paramName) {
282
+ const match = paramName.match(/job_types\/(v\d+)?\/?(.*)/);
283
+ const version = match && match[1] ? match[1] : 'v1';
284
+ const cleanedParamName = match ? match[2] : paramName;
285
+ return `/api/${version}/parameters/job_types/${cleanedParamName}`;
286
+ }
287
+ function loadParameterValue(paramUrl) {
288
+ const modal = $(`#${MODAL_ID}`);
289
+ $.ajax({
290
+ url: paramUrl,
291
+ type: 'GET',
292
+ success: function (response) {
293
+ jsonStr = JSON.stringify(response.data, null, 3);
294
+ modal.find(`#${MODAL_ID}-content`).val(jsonStr);
295
+ },
296
+ error: function (xhr, status, error) {
297
+ console.error(`Error fetching ${paramUrl}: ${error}`);
298
+ modal.find(`#${MODAL_ID}-content`).val(error);
299
+ }
300
+ });
301
+ }
302
+ function submitParameterValue(paramUrl, paramValue) {
303
+ try {
304
+ JSON.parse(paramValue);
305
+ } catch (e) {
306
+ alert('Parameter value must be valid JSON:\n' + e.message);
307
+ return;
308
+ }
309
+ $.ajax({
310
+ url: paramUrl,
311
+ type: 'PUT',
312
+ contentType: 'application/json',
313
+ data: paramValue,
314
+ success: function (response) {
315
+ alert('Parameter updated successfully');
316
+ // reload the content to verify and reformat changes
317
+ loadParameterValue(paramUrl);
318
+ },
319
+ error: function (xhr, status, error) {
320
+ var msg = `Error submitting parameter: ${error}`;
321
+ try {
322
+ msg += `\n\n${JSON.stringify(JSON.parse(xhr.responseText), null, 3)}`;
323
+ } catch (e) {
324
+ console.error('Failed to parse error response:', e.message);
325
+ }
326
+ console.error(msg);
327
+ alert(msg);
328
+ }
329
+ });
330
+ }
331
+ // Methods for param modal updates
332
+ function toggleParamTextArea(isEnabled){
333
+ const modal = $(`#${MODAL_ID}`);
334
+ modal.find(`#${MODAL_ID}-content`).toggle(isEnabled);
335
+ modal.find(`#${MODAL_ID}-param-exists-alert`).toggle(!isEnabled);
336
+ modal.find(`#${MODAL_ID}-submit-btn`).prop('disabled', !isEnabled);
337
+ }
338
+ function handleExistingParam() {
339
+ let exists = false;
340
+ const modal = $(`#${MODAL_ID}`);
341
+ const version = getCurrentVersion();
342
+ const jobType = getInputValues('Job Type', modal).input;
343
+ const taskId = getInputValues('Task ID', modal).input;
344
+ const modality = MODALITY_TASKS.includes(taskId) ? getInputValues('Modality', modal).input : null;
345
+ if (jobType && taskId && (!MODALITY_TASKS.includes(taskId) || modality)) {
346
+ const table = $('#job-params-table').DataTable();
347
+ const searchStr = `/${jobType}/tasks/${taskId}` + (modality ? `/${modality}` : '');
348
+ exists = table.column('Parameter Name:title').data().toArray().some(val => val.endsWith(searchStr));
349
+ }
350
+ toggleParamTextArea(!exists);
351
+ }
352
+ function onResetModal(reload = false) {
353
+ const modal = $(`#${MODAL_ID}`);
354
+ const action = modal.data('bs-action');
355
+ if (action === "new") {
356
+ modal.find(`#${MODAL_ID}-dropdowns input`).val('').hide();
357
+ modal.find(`#${MODAL_ID}-dropdowns select`).val('');
358
+ modal.find(`#${MODAL_ID}-modality-select`).parent().hide();
359
+ modal.find(`#${MODAL_ID}-content`).val('');
360
+ toggleParamTextArea(true);
361
+ } else if (action === "edit") {
362
+ if (reload) {
363
+ const paramName = modal.find(`#${MODAL_ID}-label`).text();
364
+ loadParameterValue(getParamUrlFromParamName(paramName));
365
+ } else {
366
+ modal.find(`#${MODAL_ID}-label, #${MODAL_ID}-content`).text('').val('');
367
+ }
368
+ }
369
+ }
370
+ // Helper methods to get current values
371
+ function getCurrentVersion() {
372
+ return $('#version-button').text().trim();
373
+ }
374
+ function getUniqueColumnValues(columnTitle) {
375
+ return Array.from(new Set($('#job-params-table').DataTable().column(`${columnTitle}:title`).data().toArray()));
376
+ }
377
+ function getInputValues(inputField, modal) {
378
+ const selector = `#${MODAL_ID}-${inputField.toLowerCase().replace(' ', '-')}`;
379
+ const dropdown = modal.find(`${selector}-select`).val()?.trim() || '';
380
+ const input = (dropdown === '__new__') ? (modal.find(`${selector}-input`).val()?.trim() || '') : dropdown;
381
+ return { dropdown, input };
382
+ }
383
+ function getValidatedInputValue(inputField, modal) {
384
+ // Check for empty string, spaces, slashes, or existing values
385
+ const { dropdown, input } = getInputValues(inputField, modal);
386
+ var error = false;
387
+ if (!input) {
388
+ error = `${inputField} cannot be empty`;
389
+ } else if (input.includes(' ') || input.includes('/')) {
390
+ error = `${inputField} cannot contain spaces or slashes`;
391
+ } else if (
392
+ dropdown === '__new__' &&
393
+ getUniqueColumnValues(inputField).some(val => val.toLowerCase() === input.toLowerCase())
394
+ ) {
395
+ error = `${inputField} "${input}" already exists. Please select it from the dropdown.`;
396
+ }
397
+ if (error) {
398
+ alert(error);
399
+ throw new Error(error);
400
+ }
401
+ return input;
402
+ }
193
403
  </script>
194
- </body>
404
+ </body>
195
405
  </html>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aind-data-transfer-service
3
- Version: 1.15.0
3
+ Version: 1.17.0
4
4
  Summary: Service that handles requests to upload data to the cloud
5
5
  Author: Allen Institute for Neural Dynamics
6
6
  License: MIT
@@ -25,13 +25,13 @@ Requires-Dist: furo; extra == "docs"
25
25
  Provides-Extra: server
26
26
  Requires-Dist: aind-data-schema<2.0,>=1.0.0; extra == "server"
27
27
  Requires-Dist: aind-data-transfer-models==0.17.0; extra == "server"
28
- Requires-Dist: aind-metadata-mapper==0.23.0; extra == "server"
28
+ Requires-Dist: aind-metadata-mapper>=0.23.0; extra == "server"
29
29
  Requires-Dist: boto3; extra == "server"
30
30
  Requires-Dist: boto3-stubs[ssm]; extra == "server"
31
- Requires-Dist: fastapi; extra == "server"
31
+ Requires-Dist: fastapi>=0.115.13; extra == "server"
32
32
  Requires-Dist: httpx; extra == "server"
33
33
  Requires-Dist: jinja2; extra == "server"
34
- Requires-Dist: starlette; extra == "server"
34
+ Requires-Dist: starlette<0.47.0,>=0.40.0; extra == "server"
35
35
  Requires-Dist: starlette_wtf; extra == "server"
36
36
  Requires-Dist: uvicorn[standard]; extra == "server"
37
37
  Requires-Dist: wtforms; extra == "server"
@@ -1,8 +1,8 @@
1
- aind_data_transfer_service/__init__.py,sha256=lho7ClOyDOm8TOnoEsiTmbuidZwwCwry8o_Vu_mJ5qI,272
1
+ aind_data_transfer_service/__init__.py,sha256=3zeB4HVxFXx2bXdNaQXcSZw0Irq7ZulbJJ5bI0ixIYo,272
2
2
  aind_data_transfer_service/log_handler.py,sha256=c7a-gLmZeRpeCUBwCz6XsTszWXQeQdR7eKZtas4llXM,1700
3
- aind_data_transfer_service/server.py,sha256=8TRxybpk8hkqPng_6mEZc9wv8ofGErgYf0BSPk-8VAU,44545
3
+ aind_data_transfer_service/server.py,sha256=1vtmqMKF_7mv2FWrRktvjPmi-slYwt4pfUy51P3WRVc,48341
4
4
  aind_data_transfer_service/configs/__init__.py,sha256=9W5GTuso9Is1B9X16RXcdb_GxasZvj6qDzOBDv0AbTc,36
5
- aind_data_transfer_service/configs/csv_handler.py,sha256=9jM0fUlWCzmqTC7ubAeFCl0eEIX5BQvHcPPPTPngcog,4374
5
+ aind_data_transfer_service/configs/csv_handler.py,sha256=hCdfAYZW_49-l1rbua5On2Tw2ks674Z-MgB_NJlIkU4,5746
6
6
  aind_data_transfer_service/configs/job_configs.py,sha256=T-h5N6lyY9xTZ_xg_5FxkyYuMdagApbE6xalxFQ-bqA,18848
7
7
  aind_data_transfer_service/configs/job_upload_template.py,sha256=aC5m1uD_YcpbggFQ-yZ7ZJSUUGX1yQqQLF3SwmljrLk,5127
8
8
  aind_data_transfer_service/hpc/__init__.py,sha256=YNc68YNlmXwKIPFMIViz_K4XzVVHkLPEBOFyO5DKMKI,53
@@ -10,15 +10,15 @@ aind_data_transfer_service/hpc/client.py,sha256=-JSxAWn96_XOIDwhsXAHK3TZAdckddUh
10
10
  aind_data_transfer_service/hpc/models.py,sha256=-7HhV16s_MUyKPy0x0FGIbnq8DPL2qJAzJO5G7003AE,16184
11
11
  aind_data_transfer_service/models/__init__.py,sha256=Meym73bEZ9nQr4QoeyhQmV3nRTYtd_4kWKPNygsBfJg,25
12
12
  aind_data_transfer_service/models/core.py,sha256=uXtPUqjxKalg-sE8MxaJr11w_T_KKBRBSJuUgwoMZlQ,10135
13
- aind_data_transfer_service/models/internal.py,sha256=MGQrPuHrR21nn4toqdTCIEDW6MG7pWRajoPqD3j-ST0,9706
14
- aind_data_transfer_service/templates/admin.html,sha256=KvQB54-mD8LL8wnDd3G9a8lDYAT8BWK13_MhpIJAiSY,1200
13
+ aind_data_transfer_service/models/internal.py,sha256=tWO1yRMu9hHLv0mt7QhOwTPWGbKApWmCf1wwajx5qjI,10681
14
+ aind_data_transfer_service/templates/admin.html,sha256=owmWgcTFzfWh5S2L82OT-0r5pOt1bl8Fh-C0zyVo1wU,1682
15
15
  aind_data_transfer_service/templates/index.html,sha256=TDmmHlhWFPnQrk6nk1OhNjC3SapZtX0TXeuUonoCO7g,11326
16
- aind_data_transfer_service/templates/job_params.html,sha256=ivofS1CjSnC87T5J2BTsNtVzq6xnUqlPZePdGt_fc4U,8854
16
+ aind_data_transfer_service/templates/job_params.html,sha256=Q5oAsuQqBiTvOOS_7BejcqPJ4rskg_ZtG1E0NAjcGJw,21063
17
17
  aind_data_transfer_service/templates/job_status.html,sha256=5lUUGZL-5urppG610qDOgpfIE-OcQH57gFWvRA5pBNM,16938
18
18
  aind_data_transfer_service/templates/job_tasks_table.html,sha256=rWFukhjZ4dhPyabe372tmi4lbQS2fyELZ7Awbn5Un4g,6181
19
19
  aind_data_transfer_service/templates/task_logs.html,sha256=y1GnQft0S50ghPb2xJDjAlefymB9a4zYdMikUFV7Tl4,918
20
- aind_data_transfer_service-1.15.0.dist-info/licenses/LICENSE,sha256=U0Y7B3gZJHXpjJVLgTQjM8e_c8w4JJpLgGhIdsoFR1Y,1092
21
- aind_data_transfer_service-1.15.0.dist-info/METADATA,sha256=XNDcKsS_NFZIZMBOip1nBheDFo8lirOA7a9P14SVtNo,2452
22
- aind_data_transfer_service-1.15.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- aind_data_transfer_service-1.15.0.dist-info/top_level.txt,sha256=XmxH0q27Jholj2-VYh-6WMrh9Lw6kkuCX_fdsj3SaFE,27
24
- aind_data_transfer_service-1.15.0.dist-info/RECORD,,
20
+ aind_data_transfer_service-1.17.0.dist-info/licenses/LICENSE,sha256=U0Y7B3gZJHXpjJVLgTQjM8e_c8w4JJpLgGhIdsoFR1Y,1092
21
+ aind_data_transfer_service-1.17.0.dist-info/METADATA,sha256=pBXxvTxzxZRam6dubbJn7oPNQfBClr1TPUvwrPB3W7U,2478
22
+ aind_data_transfer_service-1.17.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
+ aind_data_transfer_service-1.17.0.dist-info/top_level.txt,sha256=XmxH0q27Jholj2-VYh-6WMrh9Lw6kkuCX_fdsj3SaFE,27
24
+ aind_data_transfer_service-1.17.0.dist-info/RECORD,,