Skip to content

Commit 2c6f095

Browse files
chg ! tests
1 parent 94f6d84 commit 2c6f095

File tree

7 files changed

+589
-309
lines changed

7 files changed

+589
-309
lines changed

src/country_workspace/contrib/hope/client.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import requests
88
from constance import config
9-
from requests.exceptions import RequestException
9+
from requests.exceptions import RequestException, HTTPError
1010

1111
from country_workspace.exceptions import RemoteError
1212

@@ -69,21 +69,34 @@ def post(self, path: str, data: "JsonType | None") -> "FlatJsonType":
6969
url = self.get_url(path)
7070
signature = hashlib.sha256(f"{url}{data}{time.perf_counter_ns()}".encode()).hexdigest()
7171
hope_request_start.send(self.__class__, url=url, data=data, signature=signature)
72+
response = None
73+
7274
try:
73-
ret = requests.post(
75+
response = requests.post(
7476
url,
7577
json=data,
7678
headers={"Authorization": f"Token {self.token}"},
7779
timeout=10, # nosec
7880
)
79-
ret.raise_for_status()
80-
except RequestException as exc:
81-
raise RemoteError(f"Error posting to {url}: {exc}") from exc
81+
response.raise_for_status()
82+
except HTTPError as http_err:
83+
resp = http_err.response
84+
error_details = resp.text[:1000] + "..." if len(resp.text) > 1000 else resp.text
85+
raise RemoteError(
86+
f"HTTP error posting to {url}: {http_err}. "
87+
f"Status Code: {resp.status_code}. Response Body: {error_details}"
88+
) from http_err
89+
except RequestException as req_err:
90+
raise RemoteError(f"Request failed for {url}: {req_err}") from req_err
8291

8392
try:
84-
result = ret.json()
85-
except JSONDecodeError as exc:
86-
raise RemoteError(f"Wrong JSON response posting to {url}") from exc
93+
result = response.json()
94+
except JSONDecodeError as json_err:
95+
response_text = response.text if response else "N/A"
96+
raise RemoteError(
97+
f"Wrong JSON response posting to {url}. Status: {response.status_code}. Response text: {response_text}"
98+
) from json_err
8799

88100
hope_request_end.send(self.__class__, url=url, data=data, signature=signature)
101+
89102
return result

src/country_workspace/contrib/hope/push.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from country_workspace.exceptions import RemoteError
1414
from country_workspace.models import AsyncJob
1515
from country_workspace.workspaces.models import CountryHousehold, CountryIndividual
16+
from country_workspace.utils.fields import map_fields
1617

1718

1819
@dataclass
@@ -225,20 +226,3 @@ def steps() -> Iterator[Callable[[], None]]:
225226
if processor.total["errors"]:
226227
return processor.total
227228
return processor.total
228-
229-
230-
def map_fields(fields: dict[str, str]) -> dict[str, str]:
231-
"""
232-
Map keys in a dictionary to alternative names based on a predefined mapping.
233-
234-
Args:
235-
fields (dict[str, str]): A dictionary containing field names as keys and their values.
236-
237-
Returns:
238-
dict[str, str]: A new dictionary with keys mapped according to the predefined mapping.
239-
240-
"""
241-
to_map = {
242-
"gender": "sex",
243-
}
244-
return {to_map.get(k, k): v for k, v in fields.items()}

src/country_workspace/utils/fields.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
Record = Mapping[str, Any]
1111

1212

13-
TO_REMOVE = "_h_c", "_h_f", "_i_c", "_i_f"
14-
TO_UPPERCASE = "relationship", "gender", "disability", "residence_status"
13+
TO_REMOVE_VALUES = "_h_c", "_h_f", "_i_c", "_i_f"
14+
TO_UPPERCASE_FIELDS = "relationship", "gender", "disability", "residence_status"
15+
TO_MAP_FIELDS = {"gender": "sex"}
1516

1617

1718
def clean_field_name(v: str) -> str:
@@ -24,7 +25,7 @@ def clean_field_name(v: str) -> str:
2425
str: The cleaned field name.
2526
2627
"""
27-
return reduce(lambda name, substr: name.replace(substr, ""), TO_REMOVE, v.lower())
28+
return reduce(lambda name, substr: name.replace(substr, ""), TO_REMOVE_VALUES, v.lower())
2829

2930

3031
def clean_field_names(record: Record) -> Record:
@@ -52,4 +53,18 @@ def uppercase_field_value(k: str, v: Any) -> str:
5253
str: The uppercase value if applicable or the original value.
5354
5455
"""
55-
return v.upper() if isinstance(v, str) and any(k.startswith(prefix) for prefix in TO_UPPERCASE) else v
56+
return v.upper() if isinstance(v, str) and any(k.startswith(prefix) for prefix in TO_UPPERCASE_FIELDS) else v
57+
58+
59+
def map_fields(fields: dict[str, str]) -> dict[str, str]:
60+
"""
61+
Map keys in a dictionary to alternative names based on a predefined mapping.
62+
63+
Args:
64+
fields (dict[str, str]): A dictionary containing field names as keys and their values.
65+
66+
Returns:
67+
dict[str, str]: A new dictionary with keys mapped according to the predefined mapping.
68+
69+
"""
70+
return {TO_MAP_FIELDS.get(k, k): v for k, v in fields.items()}

src/country_workspace/versioning/checkers.py

Lines changed: 86 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Any
12
from django import forms
23
from hope_flex_fields.models import DataChecker, FieldDefinition, Fieldset
34

@@ -7,40 +8,45 @@
78
PEOPLE_CHECKER_NAME,
89
)
910

11+
type FieldSpec = tuple[str, FieldDefinition, dict[str, Any] | None]
1012

11-
def create_hope_checkers() -> None:
12-
_char = FieldDefinition.objects.get(field_type=forms.CharField)
13-
_date = FieldDefinition.objects.get(field_type=forms.DateField)
14-
_bool = FieldDefinition.objects.get(field_type=forms.BooleanField)
15-
_int = FieldDefinition.objects.get(field_type=forms.IntegerField)
16-
17-
_h_country = FieldDefinition.objects.get(name="CountryChoice")
18-
_h_residence = FieldDefinition.objects.get(slug="hope-hh-residencestatus")
19-
_i_gender = FieldDefinition.objects.get(slug="hope-ind-gender")
20-
_i_disability = FieldDefinition.objects.get(slug="hope-ind-disability")
21-
_i_role = FieldDefinition.objects.get(slug="hope-ind-role")
22-
_i_relationship = FieldDefinition.objects.get(slug="hope-ind-relationship")
23-
24-
_p_type = FieldDefinition.objects.get(slug="hope-people-type")
2513

26-
hh_fs, __ = Fieldset.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
27-
hh_fs.fields.get_or_create(name="address", definition=_char)
28-
hh_fs.fields.get_or_create(name="admin1", definition=_char)
29-
hh_fs.fields.get_or_create(name="admin2", definition=_char)
30-
hh_fs.fields.get_or_create(name="admin3", definition=_char)
31-
hh_fs.fields.get_or_create(name="admin4", definition=_char)
32-
hh_fs.fields.get_or_create(name="collect_individual_data", definition=_bool)
33-
hh_fs.fields.get_or_create(name="consent", definition=_bool)
34-
hh_fs.fields.get_or_create(name="country", attrs={"label": "Country", "required": True}, definition=_h_country)
35-
hh_fs.fields.get_or_create(name="country_origin", definition=_h_country)
36-
hh_fs.fields.get_or_create(name="household_id", attrs={"label": "Household ID"}, definition=_char)
37-
hh_fs.fields.get_or_create(name="name_enumerator", attrs={"label": "Enumerator"}, definition=_char)
38-
hh_fs.fields.get_or_create(name="org_enumerator", definition=_char)
39-
hh_fs.fields.get_or_create(name="registration_method", definition=_char)
40-
hh_fs.fields.get_or_create(name="residence_status", definition=_h_residence)
41-
hh_fs.fields.get_or_create(name="size", definition=_int)
14+
def create_hope_checkers() -> None:
15+
try:
16+
defs: dict[str, FieldDefinition] = {
17+
"char": FieldDefinition.objects.get(field_type=forms.CharField),
18+
"date": FieldDefinition.objects.get(field_type=forms.DateField),
19+
"bool": FieldDefinition.objects.get(field_type=forms.BooleanField),
20+
"int": FieldDefinition.objects.get(field_type=forms.IntegerField),
21+
"h_country": FieldDefinition.objects.get(name="CountryChoice"),
22+
"h_residence": FieldDefinition.objects.get(slug="hope-hh-residencestatus"),
23+
"i_gender": FieldDefinition.objects.get(slug="hope-ind-gender"),
24+
"i_disability": FieldDefinition.objects.get(slug="hope-ind-disability"),
25+
"i_role": FieldDefinition.objects.get(slug="hope-ind-role"),
26+
"i_relationship": FieldDefinition.objects.get(slug="hope-ind-relationship"),
27+
"p_type": FieldDefinition.objects.get(slug="hope-people-type"),
28+
}
29+
except FieldDefinition.DoesNotExist as e:
30+
raise LookupError(f"Could not find base FieldDefinitions needed for Hope checkers: {e}") from e
4231

43-
for segment in [
32+
household_fields_spec: list[FieldSpec] = [
33+
("address", defs["char"], None),
34+
("admin1", defs["char"], None),
35+
("admin2", defs["char"], None),
36+
("admin3", defs["char"], None),
37+
("admin4", defs["char"], None),
38+
("collect_individual_data", defs["bool"], None),
39+
("consent", defs["bool"], None),
40+
("country", defs["h_country"], {"label": "Country", "required": True}),
41+
("country_origin", defs["h_country"], None),
42+
("household_id", defs["char"], {"label": "Household ID"}),
43+
("name_enumerator", defs["char"], {"label": "Enumerator"}),
44+
("org_enumerator", defs["char"], None),
45+
("registration_method", defs["char"], None),
46+
("residence_status", defs["h_residence"], None),
47+
("size", defs["int"], None),
48+
]
49+
demographic_segments: list[str] = [
4450
"female_age_group_0_5_count",
4551
"female_age_group_6_11_count",
4652
"female_age_group_12_17_count",
@@ -62,58 +68,63 @@ def create_hope_checkers() -> None:
6268
"male_age_group_12_17_disabled_count",
6369
"male_age_group_18_59_disabled_count",
6470
"male_age_group_60_disabled_count",
65-
]:
66-
hh_fs.fields.get_or_create(name=segment, definition=_int, attrs={"required": False})
71+
]
72+
household_fields_spec.extend([(segment, defs["int"], {"required": False}) for segment in demographic_segments])
73+
74+
individual_fields_spec: list[FieldSpec] = [
75+
("address", defs["char"], None),
76+
("alternate_collector_id", defs["char"], {"label": "Alternative Collector for"}),
77+
("birth_date", defs["date"], {"label": "Birth Date", "required": True}),
78+
("disability", defs["i_disability"], {"label": "Disability"}),
79+
("estimated_birth_date", defs["bool"], {"label": "Estimated Birth Date", "required": False}),
80+
("family_name", defs["char"], {"label": "Family Name"}),
81+
("full_name", defs["char"], {"label": "Full Name", "required": True}),
82+
("gender", defs["i_gender"], None),
83+
("given_name", defs["char"], {"label": "Given Name"}),
84+
("middle_name", defs["char"], {"label": "Middle Name"}),
85+
("national_id_issuer", defs["char"], None),
86+
("national_id_no", defs["char"], None),
87+
("national_id_photo", defs["char"], None),
88+
("phone_no", defs["char"], None),
89+
("primary_collector_id", defs["char"], {"label": "Primary Collector for"}),
90+
("relationship", defs["i_relationship"], {"label": "Relationship", "required": True}),
91+
("role", defs["i_role"], {"label": "Role"}),
92+
]
6793

68-
ind_fs, __ = Fieldset.objects.get_or_create(name=INDIVIDUAL_CHECKER_NAME)
69-
ind_fs.fields.get_or_create(name="address", definition=_char)
70-
ind_fs.fields.get_or_create(
71-
name="alternate_collector_id",
72-
attrs={"label": "Alternative Collector for"},
73-
definition=_char,
74-
)
75-
ind_fs.fields.get_or_create(name="birth_date", attrs={"label": "Birth Date", "required": True}, definition=_date)
76-
ind_fs.fields.get_or_create(name="disability", attrs={"label": "Disability"}, definition=_i_disability)
77-
ind_fs.fields.get_or_create(
78-
name="estimated_birth_date", attrs={"label": "Estimated Birth Date", "required": False}, definition=_bool
79-
)
80-
ind_fs.fields.get_or_create(name="family_name", attrs={"label": "Family Name"}, definition=_char)
81-
ind_fs.fields.get_or_create(name="full_name", attrs={"label": "Full Name", "required": True}, definition=_char)
82-
ind_fs.fields.get_or_create(name="gender", definition=_i_gender)
83-
ind_fs.fields.get_or_create(name="given_name", attrs={"label": "Given Name"}, definition=_char)
84-
ind_fs.fields.get_or_create(name="middle_name", attrs={"label": "Middle Name"}, definition=_char)
85-
ind_fs.fields.get_or_create(name="national_id_issuer", definition=_char)
86-
ind_fs.fields.get_or_create(name="national_id_no", definition=_char)
87-
ind_fs.fields.get_or_create(name="national_id_photo", definition=_char)
88-
ind_fs.fields.get_or_create(name="phone_no", definition=_char)
89-
ind_fs.fields.get_or_create(name="primary_collector_id", attrs={"label": "Primary Collector for"}, definition=_char)
90-
ind_fs.fields.get_or_create(
91-
name="relationship", attrs={"label": "Relationship", "required": True}, definition=_i_relationship
92-
)
93-
ind_fs.fields.get_or_create(name="role", attrs={"label": "Role"}, definition=_i_role)
94+
people_fields_spec: list[FieldSpec] = [
95+
("type", defs["p_type"], {"label": "People Type", "required": True}),
96+
("full_name", defs["char"], {"label": "Full Name", "required": True}),
97+
("country", defs["h_country"], {"label": "Country", "required": True}),
98+
("residence_status", defs["h_residence"], {"label": "Residence Status", "required": True}),
99+
("gender", defs["i_gender"], None),
100+
("birth_date", defs["date"], {"label": "Birth Date", "required": True}),
101+
]
94102

95-
pp_fs, __ = Fieldset.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
96-
pp_fs.fields.get_or_create(name="type", attrs={"label": "People Type", "required": True}, definition=_p_type)
97-
pp_fs.fields.get_or_create(name="full_name", attrs={"label": "Full Name", "required": True}, definition=_char)
98-
pp_fs.fields.get_or_create(name="country", attrs={"label": "Country", "required": True}, definition=_h_country)
99-
pp_fs.fields.get_or_create(
100-
name="residence_status", attrs={"label": "Residence Status", "required": True}, definition=_h_residence
101-
)
102-
pp_fs.fields.get_or_create(name="gender", definition=_i_gender)
103-
pp_fs.fields.get_or_create(name="birth_date", attrs={"label": "Birth Date", "required": True}, definition=_date)
103+
def _add_fields(fieldset: Fieldset, fields_spec: list[FieldSpec]) -> None:
104+
for name, definition, attrs in fields_spec:
105+
fieldset.fields.get_or_create(name=name, definition=definition, defaults={"attrs": attrs or {}})
104106

105-
hh_dc, __ = DataChecker.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
106-
hh_dc.fieldsets.add(hh_fs)
107-
ind_dc, __ = DataChecker.objects.get_or_create(name=INDIVIDUAL_CHECKER_NAME)
108-
ind_dc.fieldsets.add(ind_fs)
109-
pp_dc, __ = DataChecker.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
110-
pp_dc.fieldsets.add(pp_fs)
107+
hh_fs, _ = Fieldset.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
108+
ind_fs, _ = Fieldset.objects.get_or_create(name=INDIVIDUAL_CHECKER_NAME)
109+
pp_fs, _ = Fieldset.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
110+
111+
_add_fields(hh_fs, household_fields_spec)
112+
_add_fields(ind_fs, individual_fields_spec)
113+
_add_fields(pp_fs, people_fields_spec)
114+
115+
hh_dc, _ = DataChecker.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
116+
ind_dc, _ = DataChecker.objects.get_or_create(name=INDIVIDUAL_CHECKER_NAME)
117+
pp_dc, _ = DataChecker.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
118+
119+
hh_dc.fieldsets.set([hh_fs])
120+
ind_dc.fieldsets.set([ind_fs])
121+
pp_dc.fieldsets.set([pp_fs])
111122

112123

113124
def removes_hope_checkers() -> None:
114125
DataChecker.objects.filter(name=HOUSEHOLD_CHECKER_NAME).delete()
115126
DataChecker.objects.filter(name=INDIVIDUAL_CHECKER_NAME).delete()
127+
DataChecker.objects.filter(name=PEOPLE_CHECKER_NAME).delete()
116128
Fieldset.objects.filter(name=HOUSEHOLD_CHECKER_NAME).delete()
117129
Fieldset.objects.filter(name=INDIVIDUAL_CHECKER_NAME).delete()
118130
Fieldset.objects.filter(name=PEOPLE_CHECKER_NAME).delete()
119-
Fieldset.objects.filter(name=PEOPLE_CHECKER_NAME).delete()

tests/contrib/hope/test_hope_client.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
from collections.abc import Callable
3+
from typing import Any
34
from unittest.mock import Mock
45

56
import pytest
@@ -182,33 +183,35 @@ def test_post_success(mocked_responses: responses.RequestsMock, mock_signals):
182183

183184

184185
@pytest.mark.parametrize(
185-
("status_code", "body", "expected_error"),
186+
("status_code", "body", "expected_error_pattern"),
186187
[
187-
(400, {"error": "Bad request"}, "Error posting to https://hope-dummy.org/api/rest/dummy_path/:"),
188-
(500, {"error": "Server error"}, "Error posting to https://hope-dummy.org/api/rest/dummy_path/:"),
189-
(200, "invalid json", "Wrong JSON response posting to https://hope-dummy.org/api/rest/dummy_path/"),
188+
pytest.param(400, {"error": "Bad request"}, r"HTTP error posting to.*?Status Code: 400", id="http_400"),
189+
pytest.param(500, {"error": "Server error"}, r"HTTP error posting to.*?Status Code: 500", id="http_500"),
190+
pytest.param(200, "invalid json", r"Wrong JSON response posting to .*?\. Status: 200", id="json_decode_error"),
190191
],
191192
)
192193
@override_config(HOPE_API_URL="https://hope-dummy.org/api/rest", HOPE_API_TOKEN="dummy_token")
194+
@pytest.mark.django_db
193195
def test_post_errors(
194196
mocked_responses: responses.RequestsMock,
195-
mock_signals,
197+
mock_signals: Any,
196198
status_code: int,
197-
body: dict | str,
198-
expected_error: str,
199-
):
199+
body: dict[str, Any] | str,
200+
expected_error_pattern: str,
201+
) -> None:
200202
start_mock, end_mock = mock_signals
201203
client = HopeClient()
202204
path = "dummy_path"
203205
url = client.get_url(path)
204206
data = {"key": "value"}
205207

206208
if isinstance(body, dict):
207-
mocked_responses.add(responses.POST, url, json=body, status=status_code)
209+
mocked_responses.add(method=responses.POST, url=url, json=body, status=status_code)
208210
else:
209-
mocked_responses.add(responses.POST, url, body=body, status=status_code)
211+
mocked_responses.add(method=responses.POST, url=url, body=body, status=status_code)
210212

211-
with pytest.raises(RemoteError, match=re.escape(expected_error)):
213+
with pytest.raises(RemoteError, match=expected_error_pattern):
212214
client.post(path, data=data)
213-
assert start_mock.call_count == 1
214-
assert end_mock.call_count == 0
215+
216+
start_mock.assert_called_once()
217+
end_mock.assert_not_called()

0 commit comments

Comments
 (0)