nmdc-runtime 2.9.0__py3-none-any.whl → 2.10.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 nmdc-runtime might be problematic. Click here for more details.

Files changed (98) hide show
  1. nmdc_runtime/api/__init__.py +0 -0
  2. nmdc_runtime/api/analytics.py +70 -0
  3. nmdc_runtime/api/boot/__init__.py +0 -0
  4. nmdc_runtime/api/boot/capabilities.py +9 -0
  5. nmdc_runtime/api/boot/object_types.py +126 -0
  6. nmdc_runtime/api/boot/triggers.py +84 -0
  7. nmdc_runtime/api/boot/workflows.py +116 -0
  8. nmdc_runtime/api/core/__init__.py +0 -0
  9. nmdc_runtime/api/core/auth.py +208 -0
  10. nmdc_runtime/api/core/idgen.py +170 -0
  11. nmdc_runtime/api/core/metadata.py +788 -0
  12. nmdc_runtime/api/core/util.py +109 -0
  13. nmdc_runtime/api/db/__init__.py +0 -0
  14. nmdc_runtime/api/db/mongo.py +447 -0
  15. nmdc_runtime/api/db/s3.py +37 -0
  16. nmdc_runtime/api/endpoints/__init__.py +0 -0
  17. nmdc_runtime/api/endpoints/capabilities.py +25 -0
  18. nmdc_runtime/api/endpoints/find.py +794 -0
  19. nmdc_runtime/api/endpoints/ids.py +192 -0
  20. nmdc_runtime/api/endpoints/jobs.py +143 -0
  21. nmdc_runtime/api/endpoints/lib/__init__.py +0 -0
  22. nmdc_runtime/api/endpoints/lib/helpers.py +274 -0
  23. nmdc_runtime/api/endpoints/lib/path_segments.py +165 -0
  24. nmdc_runtime/api/endpoints/metadata.py +260 -0
  25. nmdc_runtime/api/endpoints/nmdcschema.py +581 -0
  26. nmdc_runtime/api/endpoints/object_types.py +38 -0
  27. nmdc_runtime/api/endpoints/objects.py +277 -0
  28. nmdc_runtime/api/endpoints/operations.py +105 -0
  29. nmdc_runtime/api/endpoints/queries.py +679 -0
  30. nmdc_runtime/api/endpoints/runs.py +98 -0
  31. nmdc_runtime/api/endpoints/search.py +38 -0
  32. nmdc_runtime/api/endpoints/sites.py +229 -0
  33. nmdc_runtime/api/endpoints/triggers.py +25 -0
  34. nmdc_runtime/api/endpoints/users.py +214 -0
  35. nmdc_runtime/api/endpoints/util.py +774 -0
  36. nmdc_runtime/api/endpoints/workflows.py +353 -0
  37. nmdc_runtime/api/main.py +401 -0
  38. nmdc_runtime/api/middleware.py +43 -0
  39. nmdc_runtime/api/models/__init__.py +0 -0
  40. nmdc_runtime/api/models/capability.py +14 -0
  41. nmdc_runtime/api/models/id.py +92 -0
  42. nmdc_runtime/api/models/job.py +37 -0
  43. nmdc_runtime/api/models/lib/__init__.py +0 -0
  44. nmdc_runtime/api/models/lib/helpers.py +78 -0
  45. nmdc_runtime/api/models/metadata.py +11 -0
  46. nmdc_runtime/api/models/minter.py +0 -0
  47. nmdc_runtime/api/models/nmdc_schema.py +146 -0
  48. nmdc_runtime/api/models/object.py +180 -0
  49. nmdc_runtime/api/models/object_type.py +20 -0
  50. nmdc_runtime/api/models/operation.py +66 -0
  51. nmdc_runtime/api/models/query.py +246 -0
  52. nmdc_runtime/api/models/query_continuation.py +111 -0
  53. nmdc_runtime/api/models/run.py +161 -0
  54. nmdc_runtime/api/models/site.py +87 -0
  55. nmdc_runtime/api/models/trigger.py +13 -0
  56. nmdc_runtime/api/models/user.py +140 -0
  57. nmdc_runtime/api/models/util.py +253 -0
  58. nmdc_runtime/api/models/workflow.py +15 -0
  59. nmdc_runtime/api/openapi.py +242 -0
  60. nmdc_runtime/config.py +7 -8
  61. nmdc_runtime/core/db/Database.py +1 -3
  62. nmdc_runtime/infrastructure/database/models/user.py +0 -9
  63. nmdc_runtime/lib/extract_nmdc_data.py +0 -8
  64. nmdc_runtime/lib/nmdc_dataframes.py +3 -7
  65. nmdc_runtime/lib/nmdc_etl_class.py +1 -7
  66. nmdc_runtime/minter/adapters/repository.py +1 -2
  67. nmdc_runtime/minter/config.py +2 -0
  68. nmdc_runtime/minter/domain/model.py +35 -1
  69. nmdc_runtime/minter/entrypoints/fastapi_app.py +1 -1
  70. nmdc_runtime/mongo_util.py +1 -2
  71. nmdc_runtime/site/backup/nmdcdb_mongodump.py +1 -1
  72. nmdc_runtime/site/backup/nmdcdb_mongoexport.py +1 -3
  73. nmdc_runtime/site/export/ncbi_xml.py +1 -2
  74. nmdc_runtime/site/export/ncbi_xml_utils.py +1 -1
  75. nmdc_runtime/site/graphs.py +1 -22
  76. nmdc_runtime/site/ops.py +60 -152
  77. nmdc_runtime/site/repository.py +0 -112
  78. nmdc_runtime/site/translation/gold_translator.py +4 -12
  79. nmdc_runtime/site/translation/neon_benthic_translator.py +0 -1
  80. nmdc_runtime/site/translation/neon_soil_translator.py +4 -5
  81. nmdc_runtime/site/translation/neon_surface_water_translator.py +0 -2
  82. nmdc_runtime/site/translation/submission_portal_translator.py +2 -54
  83. nmdc_runtime/site/translation/translator.py +63 -1
  84. nmdc_runtime/site/util.py +8 -3
  85. nmdc_runtime/site/validation/util.py +10 -5
  86. nmdc_runtime/util.py +3 -47
  87. {nmdc_runtime-2.9.0.dist-info → nmdc_runtime-2.10.0.dist-info}/METADATA +57 -6
  88. nmdc_runtime-2.10.0.dist-info/RECORD +138 -0
  89. nmdc_runtime/site/translation/emsl.py +0 -43
  90. nmdc_runtime/site/translation/gold.py +0 -53
  91. nmdc_runtime/site/translation/jgi.py +0 -32
  92. nmdc_runtime/site/translation/util.py +0 -132
  93. nmdc_runtime/site/validation/jgi.py +0 -43
  94. nmdc_runtime-2.9.0.dist-info/RECORD +0 -84
  95. {nmdc_runtime-2.9.0.dist-info → nmdc_runtime-2.10.0.dist-info}/WHEEL +0 -0
  96. {nmdc_runtime-2.9.0.dist-info → nmdc_runtime-2.10.0.dist-info}/entry_points.txt +0 -0
  97. {nmdc_runtime-2.9.0.dist-info → nmdc_runtime-2.10.0.dist-info}/licenses/LICENSE +0 -0
  98. {nmdc_runtime-2.9.0.dist-info → nmdc_runtime-2.10.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,98 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from pymongo.database import Database as MongoDatabase
3
+ from starlette import status
4
+ from toolz import concat
5
+
6
+ from nmdc_runtime.api.core.util import raise404_if_none
7
+ from nmdc_runtime.api.db.mongo import get_mongo_db
8
+ from nmdc_runtime.api.endpoints.util import _request_dagster_run
9
+ from nmdc_runtime.api.models.run import (
10
+ RunSummary,
11
+ RunEvent,
12
+ RunUserSpec,
13
+ )
14
+ from nmdc_runtime.api.models.user import User, get_current_active_user
15
+ from nmdc_runtime.api.models.util import ListResponse
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.post("/runs", response_model=RunSummary)
21
+ def request_run(
22
+ run_user_spec: RunUserSpec = Depends(),
23
+ mdb: MongoDatabase = Depends(get_mongo_db),
24
+ user: User = Depends(get_current_active_user),
25
+ ):
26
+ requested = _request_dagster_run(
27
+ nmdc_workflow_id=run_user_spec.job_id,
28
+ nmdc_workflow_inputs=run_user_spec.inputs,
29
+ extra_run_config_data=run_user_spec.run_config,
30
+ mdb=mdb,
31
+ user=user,
32
+ )
33
+ if requested["type"] == "success":
34
+ return _get_run_summary(requested["detail"]["run_id"], mdb)
35
+ else:
36
+ raise HTTPException(
37
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
38
+ detail=(
39
+ f"Runtime failed to start {run_user_spec.job_id} job. "
40
+ f'Detail: {requested["detail"]}'
41
+ ),
42
+ )
43
+
44
+
45
+ def _get_run_summary(run_id, mdb) -> RunSummary:
46
+ events_in_order = list(mdb.run_events.find({"run.id": run_id}, sort=[("time", 1)]))
47
+ raise404_if_none(events_in_order or None)
48
+ # TODO put relevant outputs in outputs! (for get_study_metadata job)
49
+ return RunSummary(
50
+ id=run_id,
51
+ status=events_in_order[-1]["type"],
52
+ started_at_time=events_in_order[0]["time"],
53
+ was_started_by=events_in_order[0]["producer"],
54
+ inputs=list(concat(e["inputs"] for e in events_in_order)),
55
+ outputs=list(concat(e["outputs"] for e in events_in_order)),
56
+ job=events_in_order[-1]["job"],
57
+ producer=events_in_order[-1]["producer"],
58
+ schemaURL=events_in_order[-1]["schemaURL"],
59
+ )
60
+
61
+
62
+ @router.get(
63
+ "/runs/{run_id}", response_model=RunSummary, response_model_exclude_unset=True
64
+ )
65
+ def get_run_summary(
66
+ run_id: str,
67
+ mdb: MongoDatabase = Depends(get_mongo_db),
68
+ ):
69
+ return _get_run_summary(run_id, mdb)
70
+
71
+
72
+ @router.get("/runs/{run_id}/events", response_model=ListResponse[RunEvent])
73
+ def list_events_for_run(
74
+ run_id: str,
75
+ mdb: MongoDatabase = Depends(get_mongo_db),
76
+ ):
77
+ """List events for run, in reverse chronological order."""
78
+ raise404_if_none(mdb.run_events.find_one({"run.id": run_id}))
79
+ return {
80
+ "resources": list(mdb.run_events.find({"run.id": run_id}, sort=[("time", -1)]))
81
+ }
82
+
83
+
84
+ @router.post(
85
+ "/runs/{run_id}/events", response_model=RunEvent, response_model_exclude_unset=True
86
+ )
87
+ def post_run_event(
88
+ run_id: str,
89
+ run_event: RunEvent = Depends(),
90
+ mdb: MongoDatabase = Depends(get_mongo_db),
91
+ ):
92
+ if run_id != run_event.run.id:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST,
95
+ detail=f"Supplied run_event.run.id does not match run_id given in request URL.",
96
+ )
97
+ mdb.run_events.insert_one(run_event.model_dump())
98
+ return _get_run_summary(run_event.run.id, mdb)
@@ -0,0 +1,38 @@
1
+ import json
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from pymongo.database import Database as MongoDatabase
5
+
6
+ from nmdc_runtime.api.db.mongo import get_mongo_db
7
+ from nmdc_runtime.api.endpoints.nmdcschema import strip_oid
8
+ from nmdc_runtime.api.endpoints.util import list_resources
9
+ from nmdc_runtime.api.models.nmdc_schema import (
10
+ DataObject,
11
+ DataObjectListRequest,
12
+ list_request_filter_to_mongo_filter,
13
+ )
14
+ from nmdc_runtime.api.models.util import ListResponse, ListRequest
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get(
20
+ "/data_objects",
21
+ response_model=ListResponse[DataObject],
22
+ response_model_exclude_unset=True,
23
+ )
24
+ def data_objects(
25
+ req: DataObjectListRequest = Depends(),
26
+ mdb: MongoDatabase = Depends(get_mongo_db),
27
+ ):
28
+ filter_ = list_request_filter_to_mongo_filter(req.model_dump(exclude_unset=True))
29
+ max_page_size = filter_.pop("max_page_size", None)
30
+ page_token = filter_.pop("page_token", None)
31
+ req = ListRequest(
32
+ filter=json.dumps(filter_),
33
+ max_page_size=max_page_size,
34
+ page_token=page_token,
35
+ )
36
+ rv = list_resources(req, mdb, "data_objects")
37
+ rv["resources"] = [strip_oid(d) for d in rv["resources"]]
38
+ return rv
@@ -0,0 +1,229 @@
1
+ from typing import List, Annotated
2
+
3
+ import botocore
4
+ import pymongo.database
5
+ from fastapi import APIRouter, Depends, status, HTTPException, Path, Query
6
+ from starlette.status import HTTP_403_FORBIDDEN
7
+
8
+ from nmdc_runtime.api.core.auth import (
9
+ ClientCredentials,
10
+ get_password_hash,
11
+ )
12
+ from nmdc_runtime.api.core.idgen import generate_one_id, local_part
13
+ from nmdc_runtime.api.core.util import (
14
+ raise404_if_none,
15
+ expiry_dt_from_now,
16
+ dotted_path_for,
17
+ generate_secret,
18
+ API_SITE_ID,
19
+ )
20
+ from nmdc_runtime.api.db.mongo import get_mongo_db
21
+ from nmdc_runtime.api.db.s3 import (
22
+ get_s3_client,
23
+ presigned_url_to_put,
24
+ presigned_url_to_get,
25
+ S3_ID_NS,
26
+ )
27
+ from nmdc_runtime.api.endpoints.util import exists, list_resources
28
+ from nmdc_runtime.api.models.object import (
29
+ AccessMethod,
30
+ AccessURL,
31
+ DrsObjectBase,
32
+ DrsObjectIn,
33
+ )
34
+ from nmdc_runtime.api.models.operation import Operation, ObjectPutMetadata
35
+ from nmdc_runtime.api.models.site import (
36
+ get_current_client_site,
37
+ Site,
38
+ SiteInDB,
39
+ )
40
+ from nmdc_runtime.api.models.user import get_current_active_user, User
41
+ from nmdc_runtime.api.models.util import ListResponse, ListRequest
42
+ from nmdc_runtime.minter.bootstrap import refresh_minter_requesters_from_sites
43
+
44
+ router = APIRouter()
45
+
46
+
47
+ @router.post("/sites", status_code=status.HTTP_201_CREATED, response_model=SiteInDB)
48
+ def create_site(
49
+ site: Site,
50
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
51
+ user: User = Depends(get_current_active_user),
52
+ ):
53
+ if exists(mdb.sites, {"id": site.id}):
54
+ raise HTTPException(
55
+ status_code=status.HTTP_409_CONFLICT,
56
+ detail=f"site with supplied id {site.id} already exists",
57
+ )
58
+ mdb.sites.insert_one(site.model_dump())
59
+ refresh_minter_requesters_from_sites()
60
+ rv = mdb.users.update_one(
61
+ {"username": user.username},
62
+ {"$addToSet": {"site_admin": site.id}},
63
+ )
64
+ if rv.modified_count != 1:
65
+ raise HTTPException(
66
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
67
+ detail=f"failed to register user {user.username} as site_admin for site {site.id}.",
68
+ )
69
+ return mdb.sites.find_one({"id": site.id})
70
+
71
+
72
+ @router.get(
73
+ "/sites", response_model=ListResponse[Site], response_model_exclude_unset=True
74
+ )
75
+ def list_sites(
76
+ req: Annotated[ListRequest, Query()],
77
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
78
+ ):
79
+ return list_resources(req, mdb, "sites")
80
+
81
+
82
+ @router.get("/sites/{site_id}", response_model=Site, response_model_exclude_unset=True)
83
+ def get_site(
84
+ site_id: str,
85
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
86
+ ):
87
+ return raise404_if_none(mdb.sites.find_one({"id": site_id}))
88
+
89
+
90
+ @router.patch("/sites/{site_id}", include_in_schema=False)
91
+ def update_site():
92
+ """Not yet implemented"""
93
+ pass
94
+
95
+
96
+ @router.put("/sites/{site_id}", include_in_schema=False)
97
+ def replace_site():
98
+ """Not yet implemented"""
99
+ pass
100
+
101
+
102
+ @router.get("/sites/{site_id}/capabilities", include_in_schema=False)
103
+ def list_site_capabilities(site_id: str):
104
+ """Not yet implemented"""
105
+ pass
106
+
107
+
108
+ @router.put("/sites/{site_id}/capabilities", include_in_schema=False)
109
+ def replace_site_capabilities(site_id: str, capability_ids: List[str]):
110
+ """Not yet implemented"""
111
+ pass
112
+
113
+
114
+ def verify_client_site_pair(
115
+ site_id: str,
116
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
117
+ client_site: Site = Depends(get_current_client_site),
118
+ ):
119
+ site = raise404_if_none(
120
+ mdb.sites.find_one({"id": site_id}), detail=f"no site with ID '{site_id}'"
121
+ )
122
+ if site["id"] != client_site.id:
123
+ raise HTTPException(
124
+ status_code=HTTP_403_FORBIDDEN,
125
+ detail=f"client authorized for different site_id than {site_id}",
126
+ )
127
+
128
+
129
+ @router.post(
130
+ "/sites/{site_id}:putObject",
131
+ response_model=Operation[DrsObjectIn, ObjectPutMetadata],
132
+ dependencies=[Depends(verify_client_site_pair)],
133
+ )
134
+ def put_object_in_site(
135
+ site_id: str,
136
+ object_in: DrsObjectBase,
137
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
138
+ s3client: botocore.client.BaseClient = Depends(get_s3_client),
139
+ ):
140
+ if site_id != API_SITE_ID:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_403_FORBIDDEN,
143
+ detail=f"API-mediated object storage for site {site_id} is not enabled.",
144
+ )
145
+ expires_in = 300
146
+ object_id = generate_one_id(mdb, S3_ID_NS)
147
+ url = presigned_url_to_put(
148
+ f"{S3_ID_NS}/{object_id}",
149
+ client=s3client,
150
+ mime_type=object_in.mime_type,
151
+ expires_in=expires_in,
152
+ )
153
+ # XXX ensures defaults are set, e.g. done:false
154
+ op = Operation[DrsObjectIn, ObjectPutMetadata](
155
+ **{
156
+ "id": generate_one_id(mdb, "op"),
157
+ "expire_time": expiry_dt_from_now(days=30, seconds=expires_in),
158
+ "metadata": {
159
+ "object_id": object_id,
160
+ "site_id": site_id,
161
+ "url": url,
162
+ "expires_in_seconds": expires_in,
163
+ "model": dotted_path_for(ObjectPutMetadata),
164
+ },
165
+ }
166
+ )
167
+ mdb.operations.insert_one(op.model_dump())
168
+ return op
169
+
170
+
171
+ @router.post(
172
+ "/sites/{site_id}:getObjectLink",
173
+ response_model=AccessURL,
174
+ dependencies=[Depends(verify_client_site_pair)],
175
+ )
176
+ def get_site_object_link(
177
+ site_id: str,
178
+ access_method: AccessMethod,
179
+ s3client: botocore.client.BaseClient = Depends(get_s3_client),
180
+ ):
181
+ if site_id != API_SITE_ID:
182
+ raise HTTPException(
183
+ status_code=status.HTTP_403_FORBIDDEN,
184
+ detail=f"API-mediated object storage for site {site_id} is not enabled.",
185
+ )
186
+ url = presigned_url_to_get(
187
+ f"{S3_ID_NS}/{access_method.access_id}",
188
+ client=s3client,
189
+ )
190
+ return {"url": url}
191
+
192
+
193
+ @router.post("/sites/{site_id}:generateCredentials", response_model=ClientCredentials)
194
+ def generate_credentials_for_site_client(
195
+ site_id: str = Path(
196
+ ...,
197
+ description="The ID of the site.",
198
+ ),
199
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
200
+ user: User = Depends(get_current_active_user),
201
+ ):
202
+ """
203
+ Generate a client_id and client_secret for the given site.
204
+
205
+ You must be authenticated as a user who is registered as an admin of the given site.
206
+ """
207
+ raise404_if_none(
208
+ mdb.sites.find_one({"id": site_id}), detail=f"no site with ID '{site_id}'"
209
+ )
210
+ site_admin = mdb.users.find_one({"username": user.username, "site_admin": site_id})
211
+ if not site_admin:
212
+ raise HTTPException(
213
+ status_code=status.HTTP_403_FORBIDDEN,
214
+ detail="You're not registered as an admin for this site",
215
+ )
216
+
217
+ # XXX client_id must not contain a ':' because HTTPBasic auth splits on ':'.
218
+ client_id = local_part(generate_one_id(mdb, "site_clients"))
219
+ client_secret = generate_secret()
220
+ hashed_secret = get_password_hash(client_secret)
221
+ mdb.sites.update_one(
222
+ {"id": site_id},
223
+ {"$push": {"clients": {"id": client_id, "hashed_secret": hashed_secret}}},
224
+ )
225
+
226
+ return {
227
+ "client_id": client_id,
228
+ "client_secret": client_secret,
229
+ }
@@ -0,0 +1,25 @@
1
+ from typing import List
2
+
3
+ import pymongo
4
+ from fastapi import APIRouter, Depends
5
+
6
+ from nmdc_runtime.api.core.util import raise404_if_none
7
+ from nmdc_runtime.api.db.mongo import get_mongo_db
8
+ from nmdc_runtime.api.models.trigger import Trigger
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/triggers", response_model=List[Trigger])
14
+ def list_triggers(
15
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
16
+ ):
17
+ return list(mdb.triggers.find())
18
+
19
+
20
+ @router.get("/triggers/{trigger_id}", response_model=Trigger)
21
+ def get_trigger(
22
+ trigger_id: str,
23
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
24
+ ):
25
+ return raise404_if_none(mdb.triggers.find_one({"id": trigger_id}))
@@ -0,0 +1,214 @@
1
+ import json
2
+ from datetime import timedelta
3
+ from typing import Annotated
4
+
5
+ import pymongo.database
6
+ import requests
7
+ from fastapi import Depends, APIRouter, HTTPException, status, Cookie
8
+ from jose import jws, JWTError
9
+ from starlette.requests import Request
10
+ from starlette.responses import RedirectResponse, PlainTextResponse
11
+
12
+ from nmdc_runtime.api.core.auth import (
13
+ OAuth2PasswordOrClientCredentialsRequestForm,
14
+ Token,
15
+ ACCESS_TOKEN_EXPIRES,
16
+ create_access_token,
17
+ ORCID_NMDC_CLIENT_ID,
18
+ ORCID_JWK,
19
+ ORCID_JWS_VERITY_ALGORITHM,
20
+ credentials_exception,
21
+ ORCID_NMDC_CLIENT_SECRET,
22
+ ORCID_BASE_URL,
23
+ )
24
+ from nmdc_runtime.api.core.auth import get_password_hash
25
+ from nmdc_runtime.api.core.util import generate_secret
26
+ from nmdc_runtime.api.db.mongo import get_mongo_db
27
+ from nmdc_runtime.api.endpoints.util import BASE_URL_EXTERNAL
28
+ from nmdc_runtime.api.models.site import authenticate_site_client
29
+ from nmdc_runtime.api.models.user import UserInDB, UserIn, get_user
30
+ from nmdc_runtime.api.models.user import (
31
+ authenticate_user,
32
+ User,
33
+ get_current_active_user,
34
+ )
35
+
36
+ router = APIRouter()
37
+
38
+
39
+ @router.get("/orcid_code", response_class=RedirectResponse, include_in_schema=False)
40
+ async def receive_orcid_code(request: Request, code: str, state: str | None = None):
41
+ rv = requests.post(
42
+ f"{ORCID_BASE_URL}/oauth/token",
43
+ data=(
44
+ f"client_id={ORCID_NMDC_CLIENT_ID}&client_secret={ORCID_NMDC_CLIENT_SECRET}&"
45
+ f"grant_type=authorization_code&code={code}&redirect_uri={BASE_URL_EXTERNAL}/orcid_code"
46
+ ),
47
+ headers={
48
+ "Content-type": "application/x-www-form-urlencoded",
49
+ "Accept": "application/json",
50
+ },
51
+ )
52
+ token_response = rv.json()
53
+ response = RedirectResponse(state or request.url_for("custom_swagger_ui_html"))
54
+ for key in ["user_orcid", "user_name", "user_id_token"]:
55
+ response.set_cookie(
56
+ key=key,
57
+ value=token_response[key.replace("user_", "")],
58
+ max_age=2592000,
59
+ )
60
+ return response
61
+
62
+
63
+ @router.get("/orcid_jwt")
64
+ async def get_orcid_jwt(user_id_token: Annotated[str | None, Cookie()] = None):
65
+ if user_id_token:
66
+ return PlainTextResponse(content=user_id_token)
67
+ else:
68
+ return PlainTextResponse(content="No ORCiD cookie found. Did you log in?")
69
+
70
+
71
+ @router.post("/token", response_model=Token)
72
+ async def login_for_access_token(
73
+ form_data: OAuth2PasswordOrClientCredentialsRequestForm = Depends(),
74
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
75
+ ):
76
+ if form_data.grant_type == "password":
77
+ user = authenticate_user(mdb, form_data.username, form_data.password)
78
+ if not user:
79
+ raise HTTPException(
80
+ status_code=status.HTTP_401_UNAUTHORIZED,
81
+ detail="Incorrect username or password",
82
+ headers={"WWW-Authenticate": "Bearer"},
83
+ )
84
+ access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
85
+ access_token = create_access_token(
86
+ data={"sub": f"user:{user.username}"}, expires_delta=access_token_expires
87
+ )
88
+ else: # form_data.grant_type == "client_credentials"
89
+ # If the HTTP request didn't include a Client Secret, we validate the Client ID as an ORCID JWT.
90
+ # We get a username from that ORCID JWT and fetch the corresponding user record from our database,
91
+ # creating that user record if it doesn't already exist.
92
+ if not form_data.client_secret:
93
+ try:
94
+ payload = jws.verify(
95
+ form_data.client_id,
96
+ ORCID_JWK,
97
+ algorithms=[ORCID_JWS_VERITY_ALGORITHM],
98
+ )
99
+ payload = json.loads(payload.decode())
100
+ issuer: str = payload.get("iss")
101
+ if issuer != ORCID_BASE_URL:
102
+ raise credentials_exception
103
+ subject: str = payload.get("sub")
104
+ user = get_user(mdb, subject)
105
+ if user is None:
106
+ mdb.users.insert_one(
107
+ UserInDB(
108
+ username=subject,
109
+ hashed_password=get_password_hash(generate_secret()),
110
+ ).model_dump(exclude_unset=True)
111
+ )
112
+ user = get_user(mdb, subject)
113
+ assert user is not None, "failed to create orcid user"
114
+ access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
115
+ access_token = create_access_token(
116
+ data={"sub": f"user:{user.username}"},
117
+ expires_delta=access_token_expires,
118
+ )
119
+
120
+ except JWTError:
121
+ raise credentials_exception
122
+ else: # form_data.client_secret
123
+ site = authenticate_site_client(
124
+ mdb, form_data.client_id, form_data.client_secret
125
+ )
126
+ if not site:
127
+ raise HTTPException(
128
+ status_code=status.HTTP_401_UNAUTHORIZED,
129
+ detail="Incorrect client_id or client_secret",
130
+ headers={"WWW-Authenticate": "Bearer"},
131
+ )
132
+ # TODO make below an absolute time
133
+ access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
134
+ access_token = create_access_token(
135
+ data={"sub": f"client:{form_data.client_id}"},
136
+ expires_delta=access_token_expires,
137
+ )
138
+ return {
139
+ "access_token": access_token,
140
+ "token_type": "bearer",
141
+ "expires": ACCESS_TOKEN_EXPIRES.model_dump(),
142
+ }
143
+
144
+
145
+ @router.get(
146
+ "/users/me",
147
+ response_model=User,
148
+ response_model_exclude_unset=True,
149
+ name="Get User Information",
150
+ description="Get information about the logged-in user",
151
+ )
152
+ async def read_users_me(current_user: User = Depends(get_current_active_user)):
153
+ return current_user
154
+
155
+
156
+ def check_can_create_user(requester: User):
157
+ if "nmdc-runtime-useradmin" not in requester.site_admin:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_403_FORBIDDEN,
160
+ detail="only admins for site nmdc-runtime-useradmin are allowed to create users.",
161
+ )
162
+
163
+
164
+ @router.post(
165
+ "/users",
166
+ status_code=status.HTTP_201_CREATED,
167
+ response_model=User,
168
+ name="Create User",
169
+ description="Create new user (Admin Only)",
170
+ )
171
+ def create_user(
172
+ user_in: UserIn,
173
+ requester: User = Depends(get_current_active_user),
174
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
175
+ ):
176
+ check_can_create_user(requester)
177
+ mdb.users.insert_one(
178
+ UserInDB(
179
+ **user_in.model_dump(),
180
+ hashed_password=get_password_hash(user_in.password),
181
+ ).model_dump(exclude_unset=True)
182
+ )
183
+ return mdb.users.find_one({"username": user_in.username})
184
+
185
+
186
+ @router.put(
187
+ "/users",
188
+ status_code=status.HTTP_200_OK,
189
+ response_model=User,
190
+ name="Update User",
191
+ description="Update information about the user having the specified username (Admin Only)",
192
+ )
193
+ def update_user(
194
+ user_in: UserIn,
195
+ requester: User = Depends(get_current_active_user),
196
+ mdb: pymongo.database.Database = Depends(get_mongo_db),
197
+ ):
198
+ check_can_create_user(requester)
199
+ username = user_in.username
200
+
201
+ if user_in.password:
202
+ user_dict = UserInDB(
203
+ **user_in.model_dump(),
204
+ hashed_password=get_password_hash(
205
+ user_in.password
206
+ ), # Store the password hash
207
+ ).model_dump(exclude_unset=True)
208
+ else:
209
+ user_dict = UserIn(
210
+ **user_in.model_dump(),
211
+ ).model_dump(exclude_unset=True)
212
+
213
+ mdb.users.update_one({"username": username}, {"$set": user_dict})
214
+ return mdb.users.find_one({"username": user_in.username})