Skip to content

Commit a96d13e

Browse files
chg ! enable parsing of API IDs in UUID or Base64 format (#104)
1 parent 7820e46 commit a96d13e

File tree

4 files changed

+77
-7
lines changed

4 files changed

+77
-7
lines changed

src/country_workspace/contrib/hope/geo.py

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

88
from country_workspace.cache.manager import cache_manager
99
from country_workspace.state import state
10+
from country_workspace.utils.fields import extract_uuid
1011

1112
from ...exceptions import RemoteError
1213

@@ -69,7 +70,7 @@ def __init__(self, **kwargs: Any) -> None:
6970

7071
def validate_with_parent(self, parent_value: Any, value: Any) -> None:
7172
choices = self.get_choices_for_parent_value(parent_value, only_codes=True)
72-
if parent_value and value not in choices:
73+
if parent_value and self.prepare_value(value) not in choices:
7374
raise ValidationError("Not valid child for selected parent")
7475

7576
def get_choices_for_parent_value(
@@ -83,13 +84,12 @@ def get_choices_for_parent_value(
8384
):
8485
return [] if only_codes else [("", "")]
8586

86-
self.code_to_id = {r["p_code"]: str(r["id"]) for r in filtered}
87+
self.code_to_id = {r["p_code"]: str(extract_uuid(str(r["id"]), "Area:")) for r in filtered}
8788
self.id_to_code = {v: k for k, v in self.code_to_id.items()}
8889

89-
return {
90-
True: list(self.id_to_code),
91-
False: [("", ""), *[(r["p_code"], f"{r['p_code']} - {r['name']}") for r in filtered]],
92-
}[only_codes]
90+
if only_codes:
91+
return list(self.code_to_id)
92+
return [("", ""), *[(r["p_code"], f"{r['p_code']} - {r['name']}") for r in filtered]]
9393

9494
def prepare_value(self, value: Any) -> str | None:
9595
val = super().prepare_value(value)

src/country_workspace/contrib/hope/push.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def process_batch_response(self, response: dict | None, batch_ids: list[int]) ->
138138
139139
Args:
140140
response (dict | None): API response.
141-
batch_ids (list[int]): List of household IDs in the batch.
141+
batch_ids (list[int]): List of IDs for the batch that was pushed.
142142
143143
Returns:
144144
list[int]: List of successfully processed IDs.

src/country_workspace/utils/fields.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import binascii
12
from collections.abc import Callable, Mapping
23
from functools import reduce
34
from typing import Any
5+
from base64 import b64decode
6+
from uuid import UUID
47

58
from django.utils import timezone
69

@@ -68,3 +71,32 @@ def map_fields(fields: dict[str, str]) -> dict[str, str]:
6871
6972
"""
7073
return {TO_MAP_FIELDS.get(k, k): v for k, v in fields.items()}
74+
75+
76+
def extract_uuid(value: str, prefix: str | None = None) -> UUID:
77+
"""Extract a UUID from the given string.
78+
79+
- If `value` is already a UUID, returns it unchanged.
80+
- Otherwise attempts Base64-decoding and stripping an optional `prefix`.
81+
82+
"""
83+
if not isinstance(value, str):
84+
raise TypeError("value must be a str")
85+
if prefix is not None and not isinstance(prefix, str):
86+
raise TypeError("prefix must be a str or None")
87+
88+
try:
89+
return UUID(value)
90+
except ValueError:
91+
pass
92+
93+
try:
94+
decoded = b64decode(value, validate=True).decode()
95+
except (binascii.Error, UnicodeDecodeError):
96+
raise ValueError(f"value is neither a valid UUID nor valid Base64: {value!r}")
97+
98+
raw = decoded.removeprefix(prefix or "")
99+
try:
100+
return UUID(raw)
101+
except ValueError:
102+
raise ValueError(f"decoded data is not a valid UUID: {raw!r}")

tests/utils/test_utils_fields.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import pytest
44
from django.core.files.uploadedfile import SimpleUploadedFile
55
from pytest_mock import MockerFixture
6+
from base64 import b64encode
7+
from uuid import uuid4, UUID
68

79
from country_workspace.contrib.kobo.api.data.helpers import VALUE_FORMAT
810
from country_workspace.utils.fields import (
911
clean_field_name,
1012
TO_REMOVE_VALUES,
1113
clean_field_names,
1214
map_fields,
15+
extract_uuid,
1316
)
1417
from country_workspace.utils.flex_fields import Base64ImageInput, Base64ImageField
1518

@@ -72,3 +75,38 @@ def test_base64_image_field_content_is_encoded(mocker: MockerFixture) -> None:
7275
instance = Mock(spec=Base64ImageField)
7376

7477
assert Base64ImageField.clean(instance, file) == VALUE_FORMAT.format(mimetype=content_type, content=data)
78+
79+
80+
FAKE_UUID = uuid4()
81+
FAKE_PREFIX = "Area:"
82+
ENC_B64_PREF = b64encode(f"{FAKE_PREFIX}{FAKE_UUID}".encode()).decode()
83+
ENC_B64 = b64encode(str(FAKE_UUID).encode()).decode()
84+
ENC_B64_BAD = b64encode("hello-world".encode()).decode()
85+
86+
87+
@pytest.mark.parametrize(
88+
("value", "prefix", "expected"),
89+
[
90+
(str(FAKE_UUID), None, FAKE_UUID),
91+
(ENC_B64_PREF, FAKE_PREFIX, FAKE_UUID),
92+
(ENC_B64, None, FAKE_UUID),
93+
],
94+
ids=["raw-uuid", "b64-with-prefix", "b64-no-prefix"],
95+
)
96+
def test_extract_uuid_success(value: str, prefix: str | None, expected: UUID) -> None:
97+
assert extract_uuid(value, prefix) == expected
98+
99+
100+
@pytest.mark.parametrize(
101+
("value", "prefix", "exc_type"),
102+
[
103+
("not-a-uuid-or-base64", None, ValueError),
104+
(ENC_B64_BAD, None, ValueError),
105+
(123, None, TypeError),
106+
(str(FAKE_UUID), 123, TypeError),
107+
],
108+
ids=["invalid-string", "b64-not-uuid", "value-not-str", "prefix-not-str"],
109+
)
110+
def test_extract_uuid_errors(value: str | int, prefix: str | int | None, exc_type: type[Exception]) -> None:
111+
with pytest.raises(exc_type):
112+
extract_uuid(value, prefix)

0 commit comments

Comments
 (0)