Skip to content

Commit b11f4ab

Browse files
Merge pull request #12 from NOAA-GSL/feat/support-updating-profile
feat: /PATCH endpoint for Support Profiles
2 parents 3113da3 + 82618eb commit b11f4ab

File tree

6 files changed

+159
-7
lines changed

6 files changed

+159
-7
lines changed

python/nwsc_proxy/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ On startup, the service creates 'existing' and 'new' subdirectories at the path
8686
- Get only new (never before processed) Support Profiles. After a profile is returned to any API request, it will disappear from the "new" list, only appearing in `status=existing` filter requests.
8787
- POST `/all-events`
8888
- Create a new Support Profile to be stored by the API. The body of the request will be the JSON saved--the `id` field should be unique.
89+
- PATCH `/all-events?uuid=<some id>`
90+
- Update (complete JSON, or partial) an existing Support Profile from the API. `uuid` must match one of the saved Support Profile JSON's `id` attribute, otherwise it will return `404`.
8991
- DELETE `/all-events?uuid=<some id>`
9092
- Permanently remove an existing Support Profile from the API. `uuid` must match one of the saved Support Profile JSON's `id` attribute, otherwise it will return `404`.
9193

python/nwsc_proxy/ncp_web_service.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def handler(self):
5858
if request.method == "DELETE":
5959
return self._handle_delete()
6060

61+
if request.method == "PATCH":
62+
return self._handle_update()
63+
6164
# otherwise, must be 'GET' operation
6265
data_source = request.args.get("dataSource", None, type=str)
6366
profile_status = request.args.get("status", default="existing", type=str)
@@ -84,7 +87,7 @@ def handler(self):
8487
def _handle_delete(self) -> Response:
8588
"""Logic for DELETE requests to /all-events. Returns Response with status_code: 204 on
8689
success, 404 otherwise."""
87-
profile_id = request.args.get("uuid", default=None, type=str)
90+
profile_id = request.args.get("uuid")
8891
is_deleted = self.profile_store.delete(profile_id)
8992
if not is_deleted:
9093
return jsonify({"message": f"Profile {profile_id} not found"}), 404
@@ -113,6 +116,23 @@ def _handle_create(self) -> Response:
113116

114117
return jsonify({"message": f"Profile {profile_id} saved"}), 201
115118

119+
def _handle_update(self) -> Response:
120+
request_body: dict = request.json
121+
profile_id = request.args.get("uuid")
122+
123+
if not profile_id:
124+
return jsonify({"message": "Missing required query parameter: uuid"}), 400
125+
126+
try:
127+
updated_profile = self.profile_store.update(profile_id, request_body)
128+
except FileNotFoundError:
129+
return jsonify({"message": f"Profile {profile_id} not found"}), 404
130+
131+
return (
132+
jsonify({"message": f"Profile {profile_id} updated", "profile": updated_profile}),
133+
200,
134+
)
135+
116136

117137
class AppWrapper:
118138
"""Web server class wrapping Flask operations"""
@@ -130,7 +150,7 @@ def __init__(self, base_dir: str):
130150
"/all-events",
131151
"events",
132152
view_func=events_route.handler,
133-
methods=["GET", "POST", "DELETE"],
153+
methods=["GET", "POST", "PATCH", "DELETE"],
134154
)
135155

136156
def run(self, **kwargs):

python/nwsc_proxy/src/profile_store.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from dateutil.parser import parse as dt_parse
2121

22+
from src.utils import deep_update
23+
2224
# constants controlling the subdirectory where new vs. existing Profiles are saved
2325
NEW_SUBDIR = "new"
2426
EXISTING_SUBDIR = "existing"
@@ -243,6 +245,43 @@ def mark_as_existing(self, profile_id: str) -> bool:
243245

244246
return True
245247

248+
def update(self, profile_id: str, data: dict) -> dict:
249+
"""Update a Support Profile in storage based on its UUID.
250+
251+
Args:
252+
profile_id (str): The UUID of the Support Profile to update
253+
data (dict): The JSON attributes to apply. Can be partial Support Profile
254+
255+
Returns:
256+
dict: the latest version of the Profile, with all attribute changes applied
257+
258+
Raises:
259+
FileNotFoundError: if no Support Profile exists with the provided uuid
260+
"""
261+
logger.info("Updating profile_id %s with new attributes: %s", profile_id, data)
262+
263+
# find the profile data from the new_profiles cache, then apply updates and save over it
264+
cached_profile = next(
265+
(profile for profile in self.profile_cache if profile.id == profile_id), None
266+
)
267+
if not cached_profile:
268+
raise FileNotFoundError # Profile with this ID does not exist in cache
269+
270+
# Apply dict of edits on top of existing cached profile (combining any nested attributes)
271+
new_profile_data = deep_update(cached_profile.data, data)
272+
is_new_profile = cached_profile.is_new
273+
274+
# a bit hacky, but the fastest and least duplicative way to update a Profile
275+
# in cache + disk is to delete the existing profile and re-save with modified JSON data
276+
self.delete(profile_id)
277+
update_success = self.save(new_profile_data, is_new_profile)
278+
279+
if not update_success:
280+
logger.warning("Unable to update Profile ID %s for some reason", profile_id)
281+
return None
282+
283+
return new_profile_data
284+
246285
def delete(self, profile_id: str) -> bool:
247286
"""Delete a Support Profile profile from storage, based on its UUID.
248287

python/nwsc_proxy/src/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Misc Python utilities"""
2+
3+
# ----------------------------------------------------------------------------------
4+
# Created on Wed Jun 18 2025
5+
#
6+
# Copyright (c) 2025 Colorado State University. All rights reserved. (1)
7+
#
8+
# Contributors:
9+
# Mackenzie Grimes (1)
10+
#
11+
# ----------------------------------------------------------------------------------
12+
13+
from copy import deepcopy
14+
15+
16+
def deep_update(original: dict, updates: dict) -> dict:
17+
"""Recursively combine two dictionaries such that attributes in `changes` only
18+
overwrite the original dict's values at the deepest level (a.k.a. leaf node). Returns
19+
the original dictionary with changes updated (dictionaries not changed in place).
20+
21+
E.g.
22+
```
23+
deepupdate({'foo': {'bar': 'x', 'baz': 'x'}}, {'foo': {'bar': 'y'}})
24+
```
25+
Will result in a combined dictionary where only foo.bar was overwritten, not foo.baz:
26+
```
27+
{'foo': {'bar': 'y', 'baz': 'x'}}
28+
```
29+
"""
30+
updated_dict = deepcopy(original)
31+
for key, value in updates.items():
32+
if isinstance(original.get(key), dict) and isinstance(value, dict):
33+
updated_dict[key] = deep_update(original.get(key), value) # recurse down one level
34+
else:
35+
updated_dict[key] = value
36+
return updated_dict

python/nwsc_proxy/test/test_ncp_web_service.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,31 @@ def test_delete_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_pr
224224
def test_delete_profile_failure(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock):
225225
mock_request.method = "DELETE"
226226
mock_request.args = MultiDict({"uuid": EXAMPLE_UUID})
227-
mock_profile_store.return_value.delete.return_value = (
228-
False # delete() rejected, profile must exist
229-
)
227+
# delete() was rejected, profile must exist
228+
mock_profile_store.return_value.delete.return_value = False
229+
230+
result: tuple[Response, int] = wrapper.app.view_functions["events"]()
231+
232+
assert result[1] == 404
233+
234+
235+
def test_update_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock):
236+
mock_request.method = "PATCH"
237+
mock_request.args = MultiDict({"uuid": EXAMPLE_UUID})
238+
mock_request.json = {"name": "Some new name"}
239+
updated_profile = {"id": EXAMPLE_UUID, "name": "Some new name"}
240+
241+
mock_profile_store.return_value.update.return_value = updated_profile
242+
result: tuple[Response, int] = wrapper.app.view_functions["events"]()
243+
244+
assert result[1] == 200
245+
assert result[0].json["profile"] == updated_profile
246+
247+
248+
def test_update_profile_missing(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock):
249+
mock_request.method = "PATCH"
250+
mock_request.args = MultiDict({"uuid": EXAMPLE_UUID})
251+
mock_profile_store.return_value.update.side_effect = FileNotFoundError
230252

231253
result: tuple[Response, int] = wrapper.app.view_functions["events"]()
232254

python/nwsc_proxy/test/test_profile_store.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
import os
1515
import shutil
1616
from copy import deepcopy
17+
from datetime import datetime, UTC
1718
from glob import glob
1819

19-
from pytest import fixture
20+
from pytest import fixture, raises
2021

21-
from python.nwsc_proxy.src.profile_store import ProfileStore, NEW_SUBDIR, EXISTING_SUBDIR
22+
from python.nwsc_proxy.src.profile_store import ProfileStore, NEW_SUBDIR, EXISTING_SUBDIR, dt_parse
2223

2324
# constants
2425
STORE_BASE_DIR = os.path.join(os.path.dirname(__file__), "temp")
@@ -179,3 +180,35 @@ def test_delete_profile_failure(store: ProfileStore):
179180

180181
success = store.delete(profile_id)
181182
assert not success
183+
184+
185+
def test_update_profile_success(store: ProfileStore):
186+
profile_id = EXAMPLE_SUPPORT_PROFILE["id"]
187+
new_start_dt = "2026-01-01T12:00:00Z"
188+
new_name = "A different name"
189+
new_profile_data = {"name": new_name, "setting": {"timing": {"start": new_start_dt}}}
190+
191+
updated_profile = store.update(profile_id, new_profile_data)
192+
193+
# data returned should have updated attributes
194+
assert updated_profile["name"] == new_name
195+
assert updated_profile["setting"]["timing"]["start"] == new_start_dt
196+
# attributes at same level as any nested updated ones should not have changed
197+
assert (
198+
updated_profile["setting"]["timing"].get("durationInMinutes")
199+
== EXAMPLE_SUPPORT_PROFILE["setting"]["timing"]["durationInMinutes"]
200+
)
201+
# profile in cache should have indeed been changed
202+
refetched_profile = next((p for p in store.profile_cache if p.id == profile_id), None)
203+
assert refetched_profile.name == new_name
204+
assert datetime.fromtimestamp(refetched_profile.start_timestamp, UTC) == dt_parse(new_start_dt)
205+
206+
207+
def test_update_profile_not_found(store: ProfileStore):
208+
profile_id = "11111111-2222-3333-444444444444" # fake ID does not exist in ProfileStore
209+
new_profile_data = {"name": "A different name"}
210+
211+
with raises(FileNotFoundError) as exc:
212+
_ = store.update(profile_id, new_profile_data)
213+
214+
assert exc is not None

0 commit comments

Comments
 (0)