Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/country_workspace/config/fragments/constance.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"KOBO_MASTER_API_TOKEN": (KOBO_MASTER_API_TOKEN, "Kobo API Master Access Token", "write_only_input"),
"KOBO_PROJECT_VIEW_ID": (KOBO_PROJECT_VIEW_ID, "Kobo Project View ID", str),
"KOBO_KF_URL": (KOBO_KF_URL, "Kobo Server address", str),
"KOBO_CACHE_TTL": (86400, "Kobo data cache TTL", int),
"CACHE_TIMEOUT": (86400, "Cache Redis TTL", int),
"CACHE_BY_VERSION": (False, "Invalidate Cache on CW version change", bool),
"CONCURRENCY_GUARD": (
Expand All @@ -74,6 +75,7 @@
"KOBO_MASTER_API_TOKEN",
"KOBO_PROJECT_VIEW_ID",
"KOBO_KF_URL",
"KOBO_CACHE_TTL",
),
"Data consistency": ("CONCURRENCY_GUARD",),
}
Expand Down
18 changes: 7 additions & 11 deletions src/country_workspace/contrib/kobo/api/client/main.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
from collections.abc import Generator
from functools import partial
from typing import Final

from requests import Session

from country_workspace.contrib.kobo.api.client.auth import Auth
from country_workspace.contrib.kobo.api.client.helpers import DataGetter, get_asset_list, get_asset_list_url
from country_workspace.contrib.kobo.api.data.asset import Asset

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


class Client:
def __init__(
self, *, base_url: str, token: str, country_code: str | None = None, project_view_id: str | None = None
self,
*,
data_getter: DataGetter,
base_url: str,
country_code: str | None = None,
project_view_id: str | None = None,
) -> None:
self.url = get_asset_list_url(base_url, project_view_id, country_code)
session = Session()
session.auth = Auth(token)
self.data_getter: DataGetter = partial(session.get, headers=ACCEPT_JSON_HEADERS)
self.data_getter = data_getter

@property
def assets(self) -> Generator[Asset, None, None]:
Expand Down
69 changes: 67 additions & 2 deletions src/country_workspace/contrib/kobo/api/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
from collections.abc import Callable
from typing import Any, TypedDict

from requests import Response
from django.core.cache import cache
from requests import Response, Session, HTTPError

DataGetter = Callable[[str], Response]
UrlPredicate = Callable[[str], bool]


class ResponseDict(TypedDict):
"""Dictionary for response data."""

json: dict[str, Any]
status_code: int


class CachedResponse:
"""Wrapper resembling requests.Response."""

def __init__(self, response_dict: ResponseDict) -> None:
self._response_dict = response_dict

def json(self) -> dict[str, Any]:
return self._response_dict["json"]

@property
def status_code(self) -> int:
return self._response_dict["status_code"]

def raise_for_status(self) -> None:
pass


def data_getter_cache_key(url: str) -> str:
return f"dg:{url}"


class DataGetter:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a more intuitive name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domdinicola Does HttpDataGetter sound better?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, would be good if we add docstrings to classes

def __init__(
self,
session: Session,
cache_ttl: int,
headers: dict[str, str] | None = None,
do_not_use_cache_if: UrlPredicate | None = None,
) -> None:
self._session = session
self._headers = headers
self._cache_ttl = cache_ttl
self._do_not_use_cache_if = do_not_use_cache_if

def __call__(self, url: str) -> Response | CachedResponse:
if self._do_not_use_cache_if and self._do_not_use_cache_if(url):
return self._session.get(url, headers=self._headers)

cache_key = data_getter_cache_key(url)

if cached_value := cache.get(cache_key):
return CachedResponse(cached_value)

response = self._session.get(url, headers=self._headers)
try:
response.raise_for_status()
except HTTPError:
# don't cache failed response
return response

value: ResponseDict = {"json": response.json(), "status_code": response.status_code}
cache.set(cache_key, value, self._cache_ttl)

return response
26 changes: 24 additions & 2 deletions src/country_workspace/contrib/kobo/sync.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from typing import Any, TypedDict, cast
import re
from typing import Any, TypedDict, cast, Final

from constance import config as constance_config
from django.core.cache import cache
from requests import Session

from country_workspace.contrib.kobo.api.client.auth import Auth
from country_workspace.contrib.kobo.api.client.main import Client
from country_workspace.contrib.kobo.api.common import DataGetter
from country_workspace.contrib.kobo.api.data.asset import Asset
from country_workspace.contrib.kobo.api.data.submission import Submission
from country_workspace.contrib.kobo.models import KoboSubmission
Expand All @@ -17,12 +21,30 @@ class Config(BatchNameConfig, FailIfAlienConfig):
individual_records_field: str


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

SUBMISSION_URL_RE = re.compile(".+/assets/[^/]+/data/.*")


def is_submission_data_url(url: str) -> bool:
return bool(SUBMISSION_URL_RE.match(url))


def make_client(country_code: str | None) -> Client:
session = Session()
token = constance_config.KOBO_MASTER_API_TOKEN or constance_config.KOBO_API_TOKEN
session.auth = Auth(token)
data_getter = DataGetter(
session=session,
cache_ttl=constance_config.KOBO_CACHE_TTL,
headers=ACCEPT_JSON_HEADERS,
do_not_use_cache_if=is_submission_data_url,
)
project_view_id = constance_config.KOBO_PROJECT_VIEW_ID if constance_config.KOBO_MASTER_API_TOKEN else None

return Client(
data_getter=data_getter,
base_url=constance_config.KOBO_KF_URL,
token=token,
country_code=country_code,
project_view_id=project_view_id,
)
Expand Down
19 changes: 6 additions & 13 deletions tests/contrib/kobo/api/client/test_client_main.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
from unittest.mock import Mock

from pytest_mock import MockerFixture

from country_workspace.contrib.kobo.api.client.main import ACCEPT_JSON_HEADERS, Client
from country_workspace.contrib.kobo.api.client.main import Client


def test_client(mocker: MockerFixture) -> None:
session_class = mocker.patch("country_workspace.contrib.kobo.api.client.main.Session")
session = session_class.return_value
auth_class = mocker.patch("country_workspace.contrib.kobo.api.client.main.Auth")
auth = auth_class.return_value
partial = mocker.patch("country_workspace.contrib.kobo.api.client.main.partial")
data_getter = partial.return_value
data_getter_mock = Mock()
get_asset_list_url = mocker.patch("country_workspace.contrib.kobo.api.client.main.get_asset_list_url")
url = get_asset_list_url.return_value
get_asset_list = mocker.patch("country_workspace.contrib.kobo.api.client.main.get_asset_list")
get_asset_list.return_value = []

tuple(
Client(
data_getter=data_getter_mock,
base_url=(base_url := "https://test.org"),
token=(token := "test-token"),
country_code=(country_code := "CNT"),
project_view_id=(project_view_id := "project-view-id"),
).assets
)

get_asset_list_url.assert_called_once_with(base_url, project_view_id, country_code)
session_class.assert_called_once()
auth_class.assert_called_once_with(token)
assert session.auth == auth
partial.assert_called_once_with(session.get, headers=ACCEPT_JSON_HEADERS)
get_asset_list.assert_called_with(data_getter, url)
get_asset_list.assert_called_with(data_getter_mock, url)
14 changes: 14 additions & 0 deletions tests/contrib/kobo/api/common/test_cached_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from country_workspace.contrib.kobo.api.common import CachedResponse


def test_cached_response() -> None:
cached_response = CachedResponse(
{
"json": (expected_json := {"foo": "bar"}),
"status_code": (expected_status_code := 42),
}
)
assert cached_response.json() == expected_json
assert cached_response.status_code == expected_status_code
# we should not get an exception here
cached_response.raise_for_status()
101 changes: 101 additions & 0 deletions tests/contrib/kobo/api/common/test_data_getter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from collections.abc import Generator
from unittest.mock import Mock, MagicMock

import pytest
from pytest_mock import MockFixture
from requests import HTTPError

from country_workspace.contrib.kobo.api.common import DataGetter


URL = "https://test.org"
CACHE_TTL = 42


@pytest.fixture
def cache_mock(mocker: MockFixture) -> Mock:
return mocker.patch("country_workspace.contrib.kobo.api.common.cache")


@pytest.fixture
def session_mock() -> Generator[Mock, None, None]:
return MagicMock(name="Session()")


@pytest.fixture
def cached_response_class_mock(mocker: MockFixture) -> Mock:
return mocker.patch("country_workspace.contrib.kobo.api.common.CachedResponse")


@pytest.fixture
def data_getter_cache_key_mock(mocker: MockFixture) -> Mock:
return mocker.patch("country_workspace.contrib.kobo.api.common.data_getter_cache_key")


def test_cache_can_be_skipped(cache_mock: Mock, session_mock: Mock) -> None:
function = MagicMock()
function.return_value = True

data_getter = DataGetter(
session=session_mock,
cache_ttl=CACHE_TTL,
do_not_use_cache_if=function,
)
response = data_getter(URL)

assert response == session_mock.get.return_value
session_mock.get.assert_called_with(URL, headers=None)
function.assert_called_once_with(URL)
cache_mock.assert_not_called()


def test_cached_value_is_returned(
cache_mock: Mock, session_mock: Mock, cached_response_class_mock: Mock, data_getter_cache_key_mock: Mock
) -> None:
data_getter = DataGetter(
session=session_mock,
cache_ttl=CACHE_TTL,
)
response = data_getter(URL)

assert response == cached_response_class_mock.return_value
cached_response_class_mock.assert_called_once_with(cache_mock.get.return_value)
session_mock.get.assert_not_called()
cache_mock.get.assert_called_once_with(data_getter_cache_key_mock.return_value)
data_getter_cache_key_mock.assert_called_once_with(URL)


def test_failing_response_is_not_cached(cache_mock: Mock, session_mock: Mock) -> None:
cache_mock.get.return_value = None
session_mock.get.return_value.raise_for_status.side_effect = HTTPError()

data_getter = DataGetter(
session=session_mock,
cache_ttl=CACHE_TTL,
)
response = data_getter(URL)

assert response == session_mock.get.return_value
cache_mock.set.assert_not_called()


def test_response_is_cached(
cache_mock: Mock, session_mock: Mock, cached_response_class_mock: Mock, data_getter_cache_key_mock: Mock
) -> None:
cache_mock.get.return_value = None

data_getter = DataGetter(
session=session_mock,
cache_ttl=CACHE_TTL,
)
response = data_getter(URL)

assert response == session_mock.get.return_value
cache_mock.set.assert_called_once_with(
data_getter_cache_key_mock.return_value,
{
"json": session_mock.get.return_value.json.return_value,
"status_code": session_mock.get.return_value.status_code,
},
CACHE_TTL,
)
15 changes: 15 additions & 0 deletions tests/contrib/kobo/api/common/test_is_submission_data_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import pytest

from country_workspace.contrib.kobo.sync import is_submission_data_url


@pytest.mark.parametrize(
("url", "expected"),
[
("", False),
("https://example.com", False),
("https://example.com/api/v2/assets/abc42/data/", True),
],
)
def test_is_submission_data_url(url: str, expected: bool) -> None:
assert is_submission_data_url(url) is expected
24 changes: 19 additions & 5 deletions tests/contrib/kobo/test_kobo_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
import_data,
make_client,
Config,
ACCEPT_JSON_HEADERS,
is_submission_data_url,
)

EMPTY = ""
TOKEN = "token"
MAIN_TOKEN = "main_token"
PROJECT_ID = "project-view-id"
CACHE_TTL = 42
BATCH_NAME = "batch-name"
INDIVIDUAL_RECORDS_FIELD = "individual-records-field"
COUNTRY_CODE = "CNT"
Expand Down Expand Up @@ -53,21 +56,32 @@ def test_make_client(
expected_token: str,
expected_project_view_id: str | None,
) -> None:
client_class = mocker.patch("country_workspace.contrib.kobo.sync.Client")
expected_client = client_class.return_value
session_class_mock = mocker.patch("country_workspace.contrib.kobo.sync.Session")
auth_class_mock = mocker.patch("country_workspace.contrib.kobo.sync.Auth")
client_class_mock = mocker.patch("country_workspace.contrib.kobo.sync.Client")
data_getter_class_mock = mocker.patch("country_workspace.contrib.kobo.sync.DataGetter")

with (
override_config(KOBO_KF_URL=(url := "https://test.org")),
override_config(KOBO_MASTER_API_TOKEN=master_token),
override_config(KOBO_API_TOKEN=token),
override_config(KOBO_CACHE_TTL=CACHE_TTL),
override_config(KOBO_PROJECT_VIEW_ID=project_view_id),
):
client = make_client(country_code := "CNT")

assert client is expected_client
client_class.assert_called_once_with(
assert client is client_class_mock.return_value
session_class_mock.assert_called_once_with()
auth_class_mock.assert_called_once_with(expected_token)
data_getter_class_mock.assert_called_once_with(
session=session_class_mock.return_value,
headers=ACCEPT_JSON_HEADERS,
cache_ttl=CACHE_TTL,
do_not_use_cache_if=is_submission_data_url,
)
client_class_mock.assert_called_once_with(
data_getter=data_getter_class_mock.return_value,
base_url=url,
token=expected_token,
country_code=country_code,
project_view_id=expected_project_view_id,
)
Expand Down
Loading