Skip to content

Commit e88d8b6

Browse files
CountryChoice Field should work with isocode2 and isocode3 (#88)
* add ! country_iso_code3 to sync * chg ! use isocode2 and isocode3 as choices for CountryChoice
1 parent 8687aa5 commit e88d8b6

File tree

6 files changed

+108
-17
lines changed

6 files changed

+108
-17
lines changed

src/country_workspace/admin/locations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ class CountryAdmin(SyncAdminMixin, BaseModelAdmin):
3030
list_display = (
3131
"name",
3232
"iso_code2",
33+
"iso_code3",
3334
)
3435
search_fields = (
3536
"name",
3637
"iso_code2",
38+
"iso_code3",
3739
)
3840
sync_config = SyncConfig(model=Country, step=SyncStep.COUNTRIES, sync_handler=ContextGeoSyncHandler())
3941

src/country_workspace/contrib/hope/geo.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,32 @@ def get_choices_for_parent_value(self, parent_value: Any, only_codes: bool | Non
4747

4848

4949
class CountryChoice(forms.ChoiceField):
50-
def get_choices(self) -> list[tuple[str, str]]:
51-
ret = []
50+
def __init__(self, choices: tuple[tuple[str, str]] = (), **kwargs: Any) -> None:
51+
super().__init__(choices=choices, **kwargs)
52+
self.iso3_to_iso2 = {}
53+
self.choices = self.get_choices()
54+
55+
def get_choices(self) -> tuple[tuple[str, str]]:
5256
key = "lookups/country"
53-
if not (data := cache_manager.retrieve(key)):
57+
if data := cache_manager.retrieve(key):
58+
return self._set_choices(data)
59+
try:
5460
client = HopeClient()
55-
try:
56-
data = list(client.get("lookups/country"))
57-
cache_manager.store(key, data, timeout=300)
58-
except RemoteError as e:
59-
logger.exception(e)
60-
return ret
61-
return [(record["iso_code2"], record["name"]) for record in data]
61+
data = list(client.get("lookups/country"))
62+
cache_manager.store(key, data, timeout=300)
63+
return self._set_choices(data)
64+
except RemoteError as e:
65+
logger.exception(e)
66+
return ()
6267

63-
def __init__(self, **kwargs: Any) -> None:
64-
super().__init__(**kwargs)
65-
self.choices = self.get_choices()
68+
def prepare_value(self, value: Any) -> str | None:
69+
return super().prepare_value(self.iso3_to_iso2.get(value, value))
70+
71+
def to_python(self, value: Any) -> str | None:
72+
return super().to_python(self.iso3_to_iso2.get(value, value))
73+
74+
def _set_choices(self, data: list[dict[str, str]]) -> tuple[tuple[str, str]]:
75+
return tuple([(self.iso3_to_iso2.setdefault(rec["iso_code3"], rec["iso_code2"]), rec["name"]) for rec in data])
6676

6777

6878
class Admin1Choice(DynamicChoiceField):

src/country_workspace/contrib/hope/sync/context_geo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def sync_countries(self) -> None:
3030
SyncConfig(
3131
model=Country,
3232
path="lookups/country",
33-
prepare_defaults=lambda r: {f: r.get(f) for f in ("name", "iso_code2")},
33+
prepare_defaults=lambda r: {f: r.get(f) for f in ("name", "iso_code2", "iso_code3")},
3434
),
3535
)
3636

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from django.db import migrations, models
2+
from django.db.migrations.state import StateApps
3+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
4+
5+
6+
def fill_iso_code3(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
7+
Country = apps.get_model("country_workspace", "Country")
8+
for index, country in enumerate(Country.objects.all(), start=1):
9+
country.iso_code3 = f"{index:03d}"
10+
country.save()
11+
12+
13+
class Migration(migrations.Migration):
14+
dependencies = [
15+
("country_workspace", "0012_country_hope_id"),
16+
]
17+
18+
operations = [
19+
migrations.AddField(
20+
model_name="country",
21+
name="iso_code3",
22+
field=models.CharField(max_length=3, unique=True, null=True),
23+
),
24+
migrations.RunPython(fill_iso_code3, reverse_code=migrations.RunPython.noop),
25+
migrations.AlterField(
26+
model_name="country",
27+
name="iso_code3",
28+
field=models.CharField(max_length=3, unique=True),
29+
),
30+
]

src/country_workspace/models/locations.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99

1010
class Country(BaseModel):
11+
hope_id = models.CharField(max_length=200, unique=True, editable=False)
1112
name = models.CharField(max_length=255, db_index=True)
1213
iso_code2 = models.CharField(max_length=2, unique=True)
13-
hope_id = models.CharField(max_length=200, unique=True, editable=False)
14+
iso_code3 = models.CharField(max_length=3, unique=True)
1415

1516
class Meta:
1617
verbose_name_plural = "Countries"

tests/contrib/hope/test_geo.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from typing import TYPE_CHECKING
2-
2+
import pytest
33
from constance.test import override_config
44
from testutils.factories import FieldDefinitionFactory, FieldsetFactory, FlexFieldFactory
5+
from unittest import mock
56

6-
from country_workspace.contrib.hope.geo import Admin1Choice, CountryChoice
7+
from country_workspace.contrib.hope.geo import Admin1Choice, CountryChoice, HopeClient
8+
from country_workspace.exceptions import RemoteError
9+
from country_workspace.cache.manager import cache_manager
710

811
if TYPE_CHECKING:
912
from hope_flex_fields.models import Fieldset
@@ -102,3 +105,48 @@ def test_validate_child(db, mocked_responses):
102105

103106
errors = fs.validate([{"country": "AF", "region": "---"}])
104107
assert errors == {1: {"region": "['Not valid child for selected parent']"}}
108+
109+
110+
@override_config(HOPE_API_URL="https://dev-hope.unitst.org/api/rest/")
111+
@pytest.mark.parametrize(
112+
("value", "expected_validate", "expected_prepare"),
113+
[
114+
("AF", {}, "AF"),
115+
("AFG", {}, "AF"),
116+
("XX", {1: {"country": ["Select a valid choice. XX is not one of the available choices."]}}, "XX"),
117+
(None, {}, None),
118+
],
119+
ids=["iso_code2", "iso_code3", "invalid", "empty"],
120+
)
121+
def test_country_choice(db, mocked_responses, value, expected_validate, expected_prepare):
122+
mocked_responses.add(mocked_responses.GET, "https://dev-hope.unitst.org/api/rest/lookups/country/", json=COUNTRIES)
123+
fd = FieldDefinitionFactory(field_type=CountryChoice)
124+
fs: Fieldset = FieldsetFactory()
125+
FlexFieldFactory(name="country", definition=fd, fieldset=fs)
126+
127+
errors = fs.validate([{"country": value}])
128+
assert errors == expected_validate
129+
130+
form_class = fs.get_form_class()
131+
form = form_class(data={"country": value})
132+
assert form.fields["country"].prepare_value(value) == expected_prepare
133+
134+
135+
@pytest.mark.parametrize(
136+
("field_cls", "call", "call_args", "expected"),
137+
[
138+
(Admin1Choice, "get_choices_for_parent_value", ("AF", False), []),
139+
(Admin1Choice, "get_choices_for_parent_value", ("AF", True), []),
140+
(CountryChoice, None, None, []),
141+
],
142+
ids=["admin1", "admin1_only_codes", "country"],
143+
)
144+
@override_config(HOPE_API_URL="https://dev-hope.unitst.org/api/rest/")
145+
def test_remote_error_fields(field_cls, call, call_args, expected):
146+
with (
147+
mock.patch.object(cache_manager, "retrieve", return_value=None),
148+
mock.patch.object(HopeClient, "get", side_effect=RemoteError("API failure")),
149+
):
150+
field = field_cls()
151+
result = field.choices if call is None else getattr(field, call)(*call_args)
152+
assert result == expected

0 commit comments

Comments
 (0)