Skip to content

Commit ebf2ef3

Browse files
Merge pull request #65 from unicef/feature/241796-add-cache-on-projects-for-kobo
AB#241796: Add cache for Kobo data
2 parents 8387600 + b6b8872 commit ebf2ef3

File tree

9 files changed

+262
-33
lines changed

9 files changed

+262
-33
lines changed

src/country_workspace/config/fragments/constance.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"KOBO_MASTER_API_TOKEN": (KOBO_MASTER_API_TOKEN, "Kobo API Master Access Token", "write_only_input"),
5252
"KOBO_PROJECT_VIEW_ID": (KOBO_PROJECT_VIEW_ID, "Kobo Project View ID", str),
5353
"KOBO_KF_URL": (KOBO_KF_URL, "Kobo Server address", str),
54+
"KOBO_CACHE_TTL": (86400, "Kobo data cache TTL", int),
5455
"CACHE_TIMEOUT": (86400, "Cache Redis TTL", int),
5556
"CACHE_BY_VERSION": (False, "Invalidate Cache on CW version change", bool),
5657
"CONCURRENCY_GUARD": (
@@ -74,6 +75,7 @@
7475
"KOBO_MASTER_API_TOKEN",
7576
"KOBO_PROJECT_VIEW_ID",
7677
"KOBO_KF_URL",
78+
"KOBO_CACHE_TTL",
7779
),
7880
"Data consistency": ("CONCURRENCY_GUARD",),
7981
}

src/country_workspace/contrib/kobo/api/client/main.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
from collections.abc import Generator
2-
from functools import partial
3-
from typing import Final
42

5-
from requests import Session
6-
7-
from country_workspace.contrib.kobo.api.client.auth import Auth
83
from country_workspace.contrib.kobo.api.client.helpers import DataGetter, get_asset_list, get_asset_list_url
94
from country_workspace.contrib.kobo.api.data.asset import Asset
105

11-
ACCEPT_JSON_HEADERS: Final[dict[str, str]] = {"Accept": "application/json"}
12-
136

147
class Client:
158
def __init__(
16-
self, *, base_url: str, token: str, country_code: str | None = None, project_view_id: str | None = None
9+
self,
10+
*,
11+
data_getter: DataGetter,
12+
base_url: str,
13+
country_code: str | None = None,
14+
project_view_id: str | None = None,
1715
) -> None:
1816
self.url = get_asset_list_url(base_url, project_view_id, country_code)
19-
session = Session()
20-
session.auth = Auth(token)
21-
self.data_getter: DataGetter = partial(session.get, headers=ACCEPT_JSON_HEADERS)
17+
self.data_getter = data_getter
2218

2319
@property
2420
def assets(self) -> Generator[Asset, None, None]:
Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,77 @@
11
from collections.abc import Callable
2+
from typing import Any, TypedDict
23

3-
from requests import Response
4+
from django.core.cache import cache
5+
from requests import Response, Session, HTTPError
46

5-
DataGetter = Callable[[str], Response]
7+
UrlPredicate = Callable[[str], bool]
8+
9+
10+
class ResponseDict(TypedDict):
11+
"""Dictionary for response data."""
12+
13+
json: dict[str, Any]
14+
status_code: int
15+
16+
17+
class CachedResponse:
18+
"""Wrapper resembling requests.Response."""
19+
20+
def __init__(self, response_dict: ResponseDict) -> None:
21+
self._response_dict = response_dict
22+
23+
def json(self) -> dict[str, Any]:
24+
return self._response_dict["json"]
25+
26+
@property
27+
def status_code(self) -> int:
28+
return self._response_dict["status_code"]
29+
30+
def raise_for_status(self) -> None:
31+
pass
32+
33+
34+
def data_getter_cache_key(url: str) -> str:
35+
return f"dg:{url}"
36+
37+
38+
class DataGetter:
39+
"""
40+
An abstraction over HTTP GET request.
41+
42+
A class used to abstract HTTP GET request, so authentication, caching,
43+
etc. can be configured without touching the rest of the code.
44+
"""
45+
46+
def __init__(
47+
self,
48+
session: Session,
49+
cache_ttl: int,
50+
headers: dict[str, str] | None = None,
51+
do_not_use_cache_if: UrlPredicate | None = None,
52+
) -> None:
53+
self._session = session
54+
self._headers = headers
55+
self._cache_ttl = cache_ttl
56+
self._do_not_use_cache_if = do_not_use_cache_if
57+
58+
def __call__(self, url: str) -> Response | CachedResponse:
59+
if self._do_not_use_cache_if and self._do_not_use_cache_if(url):
60+
return self._session.get(url, headers=self._headers)
61+
62+
cache_key = data_getter_cache_key(url)
63+
64+
if cached_value := cache.get(cache_key):
65+
return CachedResponse(cached_value)
66+
67+
response = self._session.get(url, headers=self._headers)
68+
try:
69+
response.raise_for_status()
70+
except HTTPError:
71+
# don't cache failed response
72+
return response
73+
74+
value: ResponseDict = {"json": response.json(), "status_code": response.status_code}
75+
cache.set(cache_key, value, self._cache_ttl)
76+
77+
return response

src/country_workspace/contrib/kobo/sync.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
from typing import Any, TypedDict, cast
1+
import re
2+
from typing import Any, TypedDict, cast, Final
23

34
from constance import config as constance_config
45
from django.core.cache import cache
6+
from requests import Session
57

8+
from country_workspace.contrib.kobo.api.client.auth import Auth
69
from country_workspace.contrib.kobo.api.client.main import Client
10+
from country_workspace.contrib.kobo.api.common import DataGetter
711
from country_workspace.contrib.kobo.api.data.asset import Asset
812
from country_workspace.contrib.kobo.api.data.submission import Submission
913
from country_workspace.contrib.kobo.models import KoboSubmission
@@ -17,12 +21,30 @@ class Config(BatchNameConfig, FailIfAlienConfig):
1721
individual_records_field: str
1822

1923

24+
ACCEPT_JSON_HEADERS: Final[dict[str, str]] = {"Accept": "application/json"}
25+
26+
SUBMISSION_URL_RE = re.compile(".+/assets/[^/]+/data/.*")
27+
28+
29+
def is_submission_data_url(url: str) -> bool:
30+
return bool(SUBMISSION_URL_RE.match(url))
31+
32+
2033
def make_client(country_code: str | None) -> Client:
34+
session = Session()
2135
token = constance_config.KOBO_MASTER_API_TOKEN or constance_config.KOBO_API_TOKEN
36+
session.auth = Auth(token)
37+
data_getter = DataGetter(
38+
session=session,
39+
cache_ttl=constance_config.KOBO_CACHE_TTL,
40+
headers=ACCEPT_JSON_HEADERS,
41+
do_not_use_cache_if=is_submission_data_url,
42+
)
2243
project_view_id = constance_config.KOBO_PROJECT_VIEW_ID if constance_config.KOBO_MASTER_API_TOKEN else None
44+
2345
return Client(
46+
data_getter=data_getter,
2447
base_url=constance_config.KOBO_KF_URL,
25-
token=token,
2648
country_code=country_code,
2749
project_view_id=project_view_id,
2850
)
Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
1+
from unittest.mock import Mock
2+
13
from pytest_mock import MockerFixture
24

3-
from country_workspace.contrib.kobo.api.client.main import ACCEPT_JSON_HEADERS, Client
5+
from country_workspace.contrib.kobo.api.client.main import Client
46

57

68
def test_client(mocker: MockerFixture) -> None:
7-
session_class = mocker.patch("country_workspace.contrib.kobo.api.client.main.Session")
8-
session = session_class.return_value
9-
auth_class = mocker.patch("country_workspace.contrib.kobo.api.client.main.Auth")
10-
auth = auth_class.return_value
11-
partial = mocker.patch("country_workspace.contrib.kobo.api.client.main.partial")
12-
data_getter = partial.return_value
9+
data_getter_mock = Mock()
1310
get_asset_list_url = mocker.patch("country_workspace.contrib.kobo.api.client.main.get_asset_list_url")
1411
url = get_asset_list_url.return_value
1512
get_asset_list = mocker.patch("country_workspace.contrib.kobo.api.client.main.get_asset_list")
1613
get_asset_list.return_value = []
1714

1815
tuple(
1916
Client(
17+
data_getter=data_getter_mock,
2018
base_url=(base_url := "https://test.org"),
21-
token=(token := "test-token"),
2219
country_code=(country_code := "CNT"),
2320
project_view_id=(project_view_id := "project-view-id"),
2421
).assets
2522
)
2623

2724
get_asset_list_url.assert_called_once_with(base_url, project_view_id, country_code)
28-
session_class.assert_called_once()
29-
auth_class.assert_called_once_with(token)
30-
assert session.auth == auth
31-
partial.assert_called_once_with(session.get, headers=ACCEPT_JSON_HEADERS)
32-
get_asset_list.assert_called_with(data_getter, url)
25+
get_asset_list.assert_called_with(data_getter_mock, url)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from country_workspace.contrib.kobo.api.common import CachedResponse
2+
3+
4+
def test_cached_response() -> None:
5+
cached_response = CachedResponse(
6+
{
7+
"json": (expected_json := {"foo": "bar"}),
8+
"status_code": (expected_status_code := 42),
9+
}
10+
)
11+
assert cached_response.json() == expected_json
12+
assert cached_response.status_code == expected_status_code
13+
# we should not get an exception here
14+
cached_response.raise_for_status()
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from collections.abc import Generator
2+
from unittest.mock import Mock, MagicMock
3+
4+
import pytest
5+
from pytest_mock import MockFixture
6+
from requests import HTTPError
7+
8+
from country_workspace.contrib.kobo.api.common import DataGetter
9+
10+
11+
URL = "https://test.org"
12+
CACHE_TTL = 42
13+
14+
15+
@pytest.fixture
16+
def cache_mock(mocker: MockFixture) -> Mock:
17+
return mocker.patch("country_workspace.contrib.kobo.api.common.cache")
18+
19+
20+
@pytest.fixture
21+
def session_mock() -> Generator[Mock, None, None]:
22+
return MagicMock(name="Session()")
23+
24+
25+
@pytest.fixture
26+
def cached_response_class_mock(mocker: MockFixture) -> Mock:
27+
return mocker.patch("country_workspace.contrib.kobo.api.common.CachedResponse")
28+
29+
30+
@pytest.fixture
31+
def data_getter_cache_key_mock(mocker: MockFixture) -> Mock:
32+
return mocker.patch("country_workspace.contrib.kobo.api.common.data_getter_cache_key")
33+
34+
35+
def test_cache_can_be_skipped(cache_mock: Mock, session_mock: Mock) -> None:
36+
function = MagicMock()
37+
function.return_value = True
38+
39+
data_getter = DataGetter(
40+
session=session_mock,
41+
cache_ttl=CACHE_TTL,
42+
do_not_use_cache_if=function,
43+
)
44+
response = data_getter(URL)
45+
46+
assert response == session_mock.get.return_value
47+
session_mock.get.assert_called_with(URL, headers=None)
48+
function.assert_called_once_with(URL)
49+
cache_mock.assert_not_called()
50+
51+
52+
def test_cached_value_is_returned(
53+
cache_mock: Mock, session_mock: Mock, cached_response_class_mock: Mock, data_getter_cache_key_mock: Mock
54+
) -> None:
55+
data_getter = DataGetter(
56+
session=session_mock,
57+
cache_ttl=CACHE_TTL,
58+
)
59+
response = data_getter(URL)
60+
61+
assert response == cached_response_class_mock.return_value
62+
cached_response_class_mock.assert_called_once_with(cache_mock.get.return_value)
63+
session_mock.get.assert_not_called()
64+
cache_mock.get.assert_called_once_with(data_getter_cache_key_mock.return_value)
65+
data_getter_cache_key_mock.assert_called_once_with(URL)
66+
67+
68+
def test_failing_response_is_not_cached(cache_mock: Mock, session_mock: Mock) -> None:
69+
cache_mock.get.return_value = None
70+
session_mock.get.return_value.raise_for_status.side_effect = HTTPError()
71+
72+
data_getter = DataGetter(
73+
session=session_mock,
74+
cache_ttl=CACHE_TTL,
75+
)
76+
response = data_getter(URL)
77+
78+
assert response == session_mock.get.return_value
79+
cache_mock.set.assert_not_called()
80+
81+
82+
def test_response_is_cached(
83+
cache_mock: Mock, session_mock: Mock, cached_response_class_mock: Mock, data_getter_cache_key_mock: Mock
84+
) -> None:
85+
cache_mock.get.return_value = None
86+
87+
data_getter = DataGetter(
88+
session=session_mock,
89+
cache_ttl=CACHE_TTL,
90+
)
91+
response = data_getter(URL)
92+
93+
assert response == session_mock.get.return_value
94+
cache_mock.set.assert_called_once_with(
95+
data_getter_cache_key_mock.return_value,
96+
{
97+
"json": session_mock.get.return_value.json.return_value,
98+
"status_code": session_mock.get.return_value.status_code,
99+
},
100+
CACHE_TTL,
101+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pytest
2+
3+
from country_workspace.contrib.kobo.sync import is_submission_data_url
4+
5+
6+
@pytest.mark.parametrize(
7+
("url", "expected"),
8+
[
9+
("", False),
10+
("https://example.com", False),
11+
("https://example.com/api/v2/assets/abc42/data/", True),
12+
],
13+
)
14+
def test_is_submission_data_url(url: str, expected: bool) -> None:
15+
assert is_submission_data_url(url) is expected

0 commit comments

Comments
 (0)