nmdc-runtime 1.3.1__py3-none-any.whl → 2.12.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. nmdc_runtime/Dockerfile +177 -0
  2. nmdc_runtime/api/analytics.py +90 -0
  3. nmdc_runtime/api/boot/capabilities.py +9 -0
  4. nmdc_runtime/api/boot/object_types.py +126 -0
  5. nmdc_runtime/api/boot/triggers.py +84 -0
  6. nmdc_runtime/api/boot/workflows.py +116 -0
  7. nmdc_runtime/api/core/auth.py +212 -0
  8. nmdc_runtime/api/core/idgen.py +200 -0
  9. nmdc_runtime/api/core/metadata.py +777 -0
  10. nmdc_runtime/api/core/util.py +114 -0
  11. nmdc_runtime/api/db/mongo.py +436 -0
  12. nmdc_runtime/api/db/s3.py +37 -0
  13. nmdc_runtime/api/endpoints/capabilities.py +25 -0
  14. nmdc_runtime/api/endpoints/find.py +634 -0
  15. nmdc_runtime/api/endpoints/jobs.py +206 -0
  16. nmdc_runtime/api/endpoints/lib/helpers.py +274 -0
  17. nmdc_runtime/api/endpoints/lib/linked_instances.py +193 -0
  18. nmdc_runtime/api/endpoints/lib/path_segments.py +165 -0
  19. nmdc_runtime/api/endpoints/metadata.py +260 -0
  20. nmdc_runtime/api/endpoints/nmdcschema.py +515 -0
  21. nmdc_runtime/api/endpoints/object_types.py +38 -0
  22. nmdc_runtime/api/endpoints/objects.py +277 -0
  23. nmdc_runtime/api/endpoints/operations.py +78 -0
  24. nmdc_runtime/api/endpoints/queries.py +701 -0
  25. nmdc_runtime/api/endpoints/runs.py +98 -0
  26. nmdc_runtime/api/endpoints/search.py +38 -0
  27. nmdc_runtime/api/endpoints/sites.py +205 -0
  28. nmdc_runtime/api/endpoints/triggers.py +25 -0
  29. nmdc_runtime/api/endpoints/users.py +214 -0
  30. nmdc_runtime/api/endpoints/util.py +817 -0
  31. nmdc_runtime/api/endpoints/wf_file_staging.py +307 -0
  32. nmdc_runtime/api/endpoints/workflows.py +353 -0
  33. nmdc_runtime/api/entrypoint.sh +7 -0
  34. nmdc_runtime/api/main.py +495 -0
  35. nmdc_runtime/api/middleware.py +43 -0
  36. nmdc_runtime/api/models/capability.py +14 -0
  37. nmdc_runtime/api/models/id.py +92 -0
  38. nmdc_runtime/api/models/job.py +57 -0
  39. nmdc_runtime/api/models/lib/helpers.py +78 -0
  40. nmdc_runtime/api/models/metadata.py +11 -0
  41. nmdc_runtime/api/models/nmdc_schema.py +146 -0
  42. nmdc_runtime/api/models/object.py +180 -0
  43. nmdc_runtime/api/models/object_type.py +20 -0
  44. nmdc_runtime/api/models/operation.py +66 -0
  45. nmdc_runtime/api/models/query.py +246 -0
  46. nmdc_runtime/api/models/query_continuation.py +111 -0
  47. nmdc_runtime/api/models/run.py +161 -0
  48. nmdc_runtime/api/models/site.py +87 -0
  49. nmdc_runtime/api/models/trigger.py +13 -0
  50. nmdc_runtime/api/models/user.py +207 -0
  51. nmdc_runtime/api/models/util.py +260 -0
  52. nmdc_runtime/api/models/wfe_file_stages.py +122 -0
  53. nmdc_runtime/api/models/workflow.py +15 -0
  54. nmdc_runtime/api/openapi.py +178 -0
  55. nmdc_runtime/api/swagger_ui/assets/EllipsesButton.js +146 -0
  56. nmdc_runtime/api/swagger_ui/assets/EndpointSearchWidget.js +369 -0
  57. nmdc_runtime/api/swagger_ui/assets/script.js +252 -0
  58. nmdc_runtime/api/swagger_ui/assets/style.css +155 -0
  59. nmdc_runtime/api/swagger_ui/swagger_ui.py +34 -0
  60. nmdc_runtime/config.py +56 -0
  61. nmdc_runtime/minter/adapters/repository.py +22 -2
  62. nmdc_runtime/minter/config.py +30 -4
  63. nmdc_runtime/minter/domain/model.py +55 -1
  64. nmdc_runtime/minter/entrypoints/fastapi_app.py +1 -1
  65. nmdc_runtime/mongo_util.py +89 -0
  66. nmdc_runtime/site/backup/nmdcdb_mongodump.py +1 -1
  67. nmdc_runtime/site/backup/nmdcdb_mongoexport.py +1 -3
  68. nmdc_runtime/site/changesheets/data/OmicsProcessing-to-catted-Biosamples.tsv +1561 -0
  69. nmdc_runtime/site/changesheets/scripts/missing_neon_soils_ecosystem_data.py +311 -0
  70. nmdc_runtime/site/changesheets/scripts/neon_soils_add_ncbi_ids.py +210 -0
  71. nmdc_runtime/site/dagster.yaml +53 -0
  72. nmdc_runtime/site/entrypoint-daemon.sh +29 -0
  73. nmdc_runtime/site/entrypoint-dagit-readonly.sh +26 -0
  74. nmdc_runtime/site/entrypoint-dagit.sh +29 -0
  75. nmdc_runtime/site/export/ncbi_xml.py +1331 -0
  76. nmdc_runtime/site/export/ncbi_xml_utils.py +405 -0
  77. nmdc_runtime/site/export/study_metadata.py +27 -4
  78. nmdc_runtime/site/graphs.py +294 -45
  79. nmdc_runtime/site/ops.py +1008 -230
  80. nmdc_runtime/site/repair/database_updater.py +451 -0
  81. nmdc_runtime/site/repository.py +368 -133
  82. nmdc_runtime/site/resources.py +154 -80
  83. nmdc_runtime/site/translation/gold_translator.py +235 -83
  84. nmdc_runtime/site/translation/neon_benthic_translator.py +212 -188
  85. nmdc_runtime/site/translation/neon_soil_translator.py +82 -58
  86. nmdc_runtime/site/translation/neon_surface_water_translator.py +698 -0
  87. nmdc_runtime/site/translation/neon_utils.py +24 -7
  88. nmdc_runtime/site/translation/submission_portal_translator.py +616 -162
  89. nmdc_runtime/site/translation/translator.py +73 -3
  90. nmdc_runtime/site/util.py +26 -7
  91. nmdc_runtime/site/validation/emsl.py +1 -0
  92. nmdc_runtime/site/validation/gold.py +1 -0
  93. nmdc_runtime/site/validation/util.py +16 -12
  94. nmdc_runtime/site/workspace.yaml +13 -0
  95. nmdc_runtime/static/NMDC_logo.svg +1073 -0
  96. nmdc_runtime/static/ORCID-iD_icon_vector.svg +4 -0
  97. nmdc_runtime/static/README.md +5 -0
  98. nmdc_runtime/static/favicon.ico +0 -0
  99. nmdc_runtime/util.py +236 -192
  100. nmdc_runtime-2.12.0.dist-info/METADATA +45 -0
  101. nmdc_runtime-2.12.0.dist-info/RECORD +131 -0
  102. {nmdc_runtime-1.3.1.dist-info → nmdc_runtime-2.12.0.dist-info}/WHEEL +1 -2
  103. {nmdc_runtime-1.3.1.dist-info → nmdc_runtime-2.12.0.dist-info}/entry_points.txt +0 -1
  104. nmdc_runtime/containers.py +0 -14
  105. nmdc_runtime/core/db/Database.py +0 -15
  106. nmdc_runtime/core/exceptions/__init__.py +0 -23
  107. nmdc_runtime/core/exceptions/base.py +0 -47
  108. nmdc_runtime/core/exceptions/token.py +0 -13
  109. nmdc_runtime/domain/users/queriesInterface.py +0 -18
  110. nmdc_runtime/domain/users/userSchema.py +0 -37
  111. nmdc_runtime/domain/users/userService.py +0 -14
  112. nmdc_runtime/infrastructure/database/db.py +0 -3
  113. nmdc_runtime/infrastructure/database/models/user.py +0 -10
  114. nmdc_runtime/lib/__init__.py +0 -1
  115. nmdc_runtime/lib/extract_nmdc_data.py +0 -41
  116. nmdc_runtime/lib/load_nmdc_data.py +0 -121
  117. nmdc_runtime/lib/nmdc_dataframes.py +0 -829
  118. nmdc_runtime/lib/nmdc_etl_class.py +0 -402
  119. nmdc_runtime/lib/transform_nmdc_data.py +0 -1117
  120. nmdc_runtime/site/drsobjects/ingest.py +0 -93
  121. nmdc_runtime/site/drsobjects/registration.py +0 -131
  122. nmdc_runtime/site/terminusdb/generate.py +0 -198
  123. nmdc_runtime/site/terminusdb/ingest.py +0 -44
  124. nmdc_runtime/site/terminusdb/schema.py +0 -1671
  125. nmdc_runtime/site/translation/emsl.py +0 -42
  126. nmdc_runtime/site/translation/gold.py +0 -53
  127. nmdc_runtime/site/translation/jgi.py +0 -31
  128. nmdc_runtime/site/translation/util.py +0 -132
  129. nmdc_runtime/site/validation/jgi.py +0 -42
  130. nmdc_runtime-1.3.1.dist-info/METADATA +0 -181
  131. nmdc_runtime-1.3.1.dist-info/RECORD +0 -81
  132. nmdc_runtime-1.3.1.dist-info/top_level.txt +0 -1
  133. /nmdc_runtime/{client → api}/__init__.py +0 -0
  134. /nmdc_runtime/{core → api/boot}/__init__.py +0 -0
  135. /nmdc_runtime/{core/db → api/core}/__init__.py +0 -0
  136. /nmdc_runtime/{domain → api/db}/__init__.py +0 -0
  137. /nmdc_runtime/{domain/users → api/endpoints}/__init__.py +0 -0
  138. /nmdc_runtime/{infrastructure → api/endpoints/lib}/__init__.py +0 -0
  139. /nmdc_runtime/{infrastructure/database → api/models}/__init__.py +0 -0
  140. /nmdc_runtime/{infrastructure/database/models → api/models/lib}/__init__.py +0 -0
  141. /nmdc_runtime/{site/drsobjects/__init__.py → api/models/minter.py} +0 -0
  142. /nmdc_runtime/site/{terminusdb → repair}/__init__.py +0 -0
  143. {nmdc_runtime-1.3.1.dist-info → nmdc_runtime-2.12.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,277 @@
1
+ from typing import List, Annotated
2
+
3
+ import botocore
4
+ from fastapi import APIRouter, status, Depends, HTTPException, Query
5
+ from gridfs import GridFS
6
+ from pymongo import ReturnDocument
7
+ from pymongo.database import Database as MongoDatabase
8
+ import requests
9
+ from starlette.responses import RedirectResponse
10
+ from toolz import merge
11
+
12
+ from nmdc_runtime.api.core.idgen import decode_id, generate_one_id, local_part
13
+ from nmdc_runtime.api.core.util import raise404_if_none, API_SITE_ID
14
+ from nmdc_runtime.api.db.mongo import get_mongo_db
15
+ from nmdc_runtime.api.db.s3 import S3_ID_NS, presigned_url_to_get, get_s3_client
16
+ from nmdc_runtime.api.endpoints.util import (
17
+ list_resources,
18
+ _create_object,
19
+ HOSTNAME_EXTERNAL,
20
+ BASE_URL_EXTERNAL,
21
+ strip_oid,
22
+ )
23
+ from nmdc_runtime.api.models.metadata import Doc
24
+ from nmdc_runtime.api.models.object import (
25
+ DrsId,
26
+ DrsObject,
27
+ DrsObjectIn,
28
+ AccessURL,
29
+ )
30
+ from nmdc_runtime.api.models.object_type import ObjectType, DrsObjectWithTypes
31
+ from nmdc_runtime.api.models.site import Site, get_current_client_site
32
+ from nmdc_runtime.api.models.util import ListRequest, ListResponse
33
+ from nmdc_runtime.minter.config import typecodes
34
+
35
+ router = APIRouter()
36
+
37
+
38
+ def supplied_object_id(mdb, client_site, obj_doc):
39
+ if "access_methods" not in obj_doc:
40
+ return None
41
+ for method in obj_doc["access_methods"]:
42
+ if method.get("access_id") and ":" in method["access_id"]:
43
+ site_id, _, object_id = method["access_id"].rpartition(":")
44
+ if (
45
+ client_site.id == site_id
46
+ and mdb.sites.count_documents({"id": site_id})
47
+ and mdb.ids.count_documents(
48
+ {"_id": decode_id(object_id), "ns": S3_ID_NS}
49
+ )
50
+ and mdb.objects.count_documents({"id": object_id}) == 0
51
+ ):
52
+ return object_id
53
+ return None
54
+
55
+
56
+ @router.post("/objects", status_code=status.HTTP_201_CREATED, response_model=DrsObject)
57
+ def create_object(
58
+ object_in: DrsObjectIn,
59
+ mdb: MongoDatabase = Depends(get_mongo_db),
60
+ client_site: Site = Depends(get_current_client_site),
61
+ ):
62
+ """Create a new DrsObject.
63
+
64
+ You may create a *blob* or a *bundle*.
65
+
66
+ A *blob* is like a file - it's a single blob of bytes, so there there is no `contents` array,
67
+ only one or more `access_methods`.
68
+
69
+ A *bundle* is like a folder - it's a gathering of other objects (blobs and/or bundles) in a
70
+ `contents` array, and `access_methods` is optional because a data consumer can fetch each of
71
+ the bundle contents individually.
72
+
73
+ At least one checksum is required. The names of supported checksum types are given by
74
+ the set of Python 3.8 `hashlib.algorithms_guaranteed`:
75
+
76
+ > blake2b | blake2s | md5 | sha1 | sha224 | sha256 | sha384 | sha3_224 | sha3_256 | sha3_384 |
77
+ > sha3_512 | sha512 | shake_128 | shake_256
78
+
79
+ Each provided `access_method` needs either an `access_url` or an `access_id`.
80
+
81
+ """
82
+ id_supplied = supplied_object_id(
83
+ mdb, client_site, object_in.model_dump(exclude_unset=True)
84
+ )
85
+ drs_id = local_part(
86
+ id_supplied if id_supplied is not None else generate_one_id(mdb, S3_ID_NS)
87
+ )
88
+ self_uri = f"drs://{HOSTNAME_EXTERNAL}/{drs_id}"
89
+ return _create_object(
90
+ mdb, object_in, mgr_site=client_site.id, drs_id=drs_id, self_uri=self_uri
91
+ )
92
+
93
+
94
+ # Note: We use the generic `Doc` class—instead of the `DrsObject` class—to describe the response
95
+ # because this endpoint (via `ListRequest`) supports projection, which can be used to omit
96
+ # fields from the response, even fields the `DrsObject` class says are required.
97
+ @router.get("/objects", response_model=ListResponse[Doc])
98
+ def list_objects(
99
+ req: Annotated[ListRequest, Query()],
100
+ mdb: MongoDatabase = Depends(get_mongo_db),
101
+ ):
102
+ rv = list_resources(req, mdb, "objects")
103
+ rv["resources"] = [strip_oid(d) for d in rv["resources"]]
104
+ return rv
105
+
106
+
107
+ @router.get(
108
+ "/objects/{object_id}", response_model=DrsObject, response_model_exclude_unset=True
109
+ )
110
+ def get_object_info(
111
+ object_id: DrsId,
112
+ mdb: MongoDatabase = Depends(get_mongo_db),
113
+ ):
114
+ """
115
+ Resolution strategy:
116
+
117
+ 0. if object_id == 'nmdc', go to <https://microbiomedata.github.io/nmdc-schema/>.
118
+ 1. if object_id.startswith("sty"): # nmdc:Study typecode
119
+ then try https://data.microbiomedata.org/details/study/nmdc:{object_id}
120
+ 2. if object_id.startswith("bsm"): # nmdc:Biosample typecode
121
+ then try https://data.microbiomedata.org/details/sample/nmdc:{object_id}
122
+ 3. if object_id.startswith some known typecode
123
+ then try https://api.microbiomedata.org/nmdcschema/ids/nmdc:{object_id}
124
+ 4. try https://microbiomedata.github.io/nmdc-schema/{object_id}
125
+ 5. try mdb.objects.find_one({"id": object_id})
126
+ """
127
+ if object_id == "nmdc":
128
+ return RedirectResponse(
129
+ "https://microbiomedata.github.io/nmdc-schema",
130
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
131
+ )
132
+ if object_id.startswith("sty-"):
133
+ url_to_try = f"https://data.microbiomedata.org/api/study/nmdc:{object_id}"
134
+ # TODO: Update this HTTP request to use the HTTP "HEAD" method once the upstream endpoint supports that method.
135
+ rv = requests.get(url_to_try, allow_redirects=True)
136
+ if rv.status_code != 404:
137
+ return RedirectResponse(
138
+ f"https://data.microbiomedata.org/details/study/nmdc:{object_id}",
139
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
140
+ )
141
+ elif object_id.startswith("bsm-"):
142
+ url_to_try = f"https://data.microbiomedata.org/api/biosample/nmdc:{object_id}"
143
+ # TODO: Update this HTTP request to use the HTTP "HEAD" method once the upstream endpoint supports that method.
144
+ rv = requests.get(url_to_try, allow_redirects=True)
145
+ if rv.status_code != 404:
146
+ return RedirectResponse(
147
+ f"https://data.microbiomedata.org/details/sample/nmdc:{object_id}",
148
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
149
+ )
150
+
151
+ # If "sty" or "bsm" ID doesn't have preferred landing page (above), try for JSON payload
152
+ if any(object_id.startswith(f'{t["name"]}-') for t in typecodes()):
153
+ url_to_try = f"{BASE_URL_EXTERNAL}/nmdcschema/ids/nmdc:{object_id}"
154
+ rv = requests.head(url_to_try, allow_redirects=True)
155
+ if rv.status_code != 404:
156
+ return RedirectResponse(
157
+ url_to_try, status_code=status.HTTP_307_TEMPORARY_REDIRECT
158
+ )
159
+
160
+ url_to_try = f"https://microbiomedata.github.io/nmdc-schema/{object_id}"
161
+ rv = requests.head(url_to_try, allow_redirects=True)
162
+ print(rv.status_code)
163
+ if rv.status_code != 404:
164
+ return RedirectResponse(
165
+ url_to_try, status_code=status.HTTP_307_TEMPORARY_REDIRECT
166
+ )
167
+
168
+ return raise404_if_none(mdb.objects.find_one({"id": object_id}))
169
+
170
+
171
+ @router.get(
172
+ "/ga4gh/drs/v1/objects/{object_id}",
173
+ summary="Get Object Info",
174
+ response_model=DrsObject,
175
+ responses={
176
+ status.HTTP_303_SEE_OTHER: {
177
+ "description": "See other",
178
+ "headers": {"Location": {"schema": {"type": "string"}}},
179
+ },
180
+ },
181
+ )
182
+ def get_ga4gh_object_info(object_id: DrsId):
183
+ """Redirect to /objects/{object_id}."""
184
+ return RedirectResponse(
185
+ BASE_URL_EXTERNAL + f"/objects/{object_id}",
186
+ status_code=status.HTTP_303_SEE_OTHER,
187
+ )
188
+
189
+
190
+ @router.get("/objects/{object_id}/types", response_model=List[ObjectType])
191
+ def list_object_types(object_id: DrsId, mdb: MongoDatabase = Depends(get_mongo_db)):
192
+ doc = raise404_if_none(mdb.objects.find_one({"id": object_id}, ["types"]))
193
+ return list(mdb.object_types.find({"id": {"$in": doc.get("types", [])}}))
194
+
195
+
196
+ @router.put("/objects/{object_id}/types", response_model=DrsObjectWithTypes)
197
+ def replace_object_types(
198
+ object_id: str,
199
+ object_type_ids: List[str],
200
+ mdb: MongoDatabase = Depends(get_mongo_db),
201
+ ):
202
+ unknown_type_ids = set(object_type_ids) - set(mdb.object_types.distinct("id"))
203
+ if unknown_type_ids:
204
+ raise HTTPException(
205
+ status_code=status.HTTP_400_BAD_REQUEST,
206
+ detail=f"unknown type ids: {unknown_type_ids}.",
207
+ )
208
+ doc_after = mdb.objects.find_one_and_update(
209
+ {"id": object_id},
210
+ {"$set": {"types": object_type_ids}},
211
+ return_document=ReturnDocument.AFTER,
212
+ )
213
+ return doc_after
214
+
215
+
216
+ def object_access_id_ok(obj_doc, access_id):
217
+ if "access_methods" not in obj_doc:
218
+ return False
219
+ for method in obj_doc["access_methods"]:
220
+ if method.get("access_id") and method["access_id"] == access_id:
221
+ return True
222
+ return False
223
+
224
+
225
+ @router.get("/objects/{object_id}/access/{access_id}", response_model=AccessURL)
226
+ def get_object_access(
227
+ object_id: DrsId,
228
+ access_id: str,
229
+ mdb: MongoDatabase = Depends(get_mongo_db),
230
+ s3client: botocore.client.BaseClient = Depends(get_s3_client),
231
+ ):
232
+ obj_doc = raise404_if_none(mdb.objects.find_one({"id": object_id}))
233
+ if not object_access_id_ok(obj_doc, access_id):
234
+ raise HTTPException(
235
+ status_code=status.HTTP_404_NOT_FOUND,
236
+ detail="access_id not referenced by object",
237
+ )
238
+ if access_id.startswith(f"{API_SITE_ID}:"):
239
+ url = presigned_url_to_get(
240
+ f"{S3_ID_NS}/{access_id.split(':', maxsplit=1)[1]}",
241
+ client=s3client,
242
+ )
243
+ return {"url": url}
244
+ if access_id.startswith("gfs0") and object_id == access_id:
245
+ mdb_fs = GridFS(mdb)
246
+ if mdb_fs.exists(_id=access_id):
247
+ return {"url": BASE_URL_EXTERNAL + f"/metadata/stored_files/{access_id}"}
248
+ else:
249
+ raise HTTPException(
250
+ status_code=status.HTTP_404_NOT_FOUND,
251
+ detail="access_id for object not found by gfs0 handler",
252
+ )
253
+
254
+ raise HTTPException(
255
+ status_code=status.HTTP_404_NOT_FOUND,
256
+ detail="no site found to handle access_id for object",
257
+ )
258
+
259
+
260
+ @router.patch("/objects/{object_id}", response_model=DrsObject)
261
+ def update_object(
262
+ object_id: str,
263
+ object_patch: DrsObjectIn,
264
+ mdb: MongoDatabase = Depends(get_mongo_db),
265
+ client_site: Site = Depends(get_current_client_site),
266
+ ):
267
+ doc = raise404_if_none(mdb.objects.find_one({"id": object_id}))
268
+ # A site client can update object iff its site_id is _mgr_site.
269
+ object_mgr_site = doc.get("_mgr_site")
270
+ if object_mgr_site != client_site.id:
271
+ raise HTTPException(
272
+ status_code=status.HTTP_403_FORBIDDEN,
273
+ detail=f"client authorized for different site_id than {object_mgr_site}",
274
+ )
275
+ doc_object_patched = merge(doc, object_patch.model_dump(exclude_unset=True))
276
+ mdb.operations.replace_one({"id": object_id}, doc_object_patched)
277
+ return doc_object_patched
@@ -0,0 +1,78 @@
1
+ from typing import Annotated
2
+
3
+ import pymongo
4
+ from fastapi import APIRouter, Depends, status, HTTPException, Query
5
+ from toolz import get_in, merge, assoc
6
+
7
+ from nmdc_runtime.api.core.util import raise404_if_none, pick
8
+ from nmdc_runtime.api.db.mongo import get_mongo_db
9
+ from nmdc_runtime.api.endpoints.util import list_resources
10
+ from nmdc_runtime.api.models.operation import (
11
+ ListOperationsResponse,
12
+ ResultT,
13
+ MetadataT,
14
+ Operation,
15
+ UpdateOperationRequest,
16
+ )
17
+ from nmdc_runtime.api.models.site import Site, get_current_client_site
18
+ from nmdc_runtime.api.models.util import ListRequest
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ @router.get("/operations", response_model=ListOperationsResponse[ResultT, MetadataT])
24
+ def list_operations(
25
+ req: Annotated[ListRequest, Query()],
26
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
27
+ ):
28
+ return list_resources(req, mdb, "operations")
29
+
30
+
31
+ @router.get("/operations/{op_id}", response_model=Operation[ResultT, MetadataT])
32
+ def get_operation(
33
+ op_id: str,
34
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
35
+ ):
36
+ op = raise404_if_none(mdb.operations.find_one({"id": op_id}))
37
+ return op
38
+
39
+
40
+ @router.patch("/operations/{op_id}", response_model=Operation[ResultT, MetadataT])
41
+ def update_operation(
42
+ op_id: str,
43
+ op_patch: UpdateOperationRequest,
44
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
45
+ client_site: Site = Depends(get_current_client_site),
46
+ ):
47
+ """
48
+
49
+ A site client can update an operation if and only if its site_id is the operation's
50
+ `metadata.site_id`.
51
+
52
+ The following fields in `metadata` are used by the system and are read-only:
53
+ - site_id
54
+ - job
55
+ - model
56
+ """
57
+ # TODO be able to make job "undone" and "redone" to re-trigger downstream ETL.
58
+ doc_op = raise404_if_none(mdb.operations.find_one({"id": op_id}))
59
+ site_id_op = get_in(["metadata", "site_id"], doc_op)
60
+ if site_id_op != client_site.id:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_403_FORBIDDEN,
63
+ detail=f"client authorized for different site_id than {site_id_op}",
64
+ )
65
+ op_patch_metadata = merge(
66
+ op_patch.model_dump(exclude_unset=True).get("metadata", {}),
67
+ pick(["site_id", "job", "model"], doc_op.get("metadata", {})),
68
+ )
69
+ doc_op_patched = merge(
70
+ doc_op,
71
+ assoc(
72
+ op_patch.model_dump(exclude_unset=True),
73
+ "metadata",
74
+ op_patch_metadata,
75
+ ),
76
+ )
77
+ mdb.operations.replace_one({"id": op_id}, doc_op_patched)
78
+ return doc_op_patched