diff --git a/pyproject.toml b/pyproject.toml
index ce316af5..2358bae6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ dependencies = [
"unicef-security>=1.5.1",
"django-pghistory>=3.5.4",
"django-anymail[mailjet]>=13.0",
+ "pillow>=11.2.1",
]
[project.scripts]
celery-monitor = "country_workspace.__monitor__:run"
diff --git a/src/country_workspace/utils/flex_fields.py b/src/country_workspace/utils/flex_fields.py
index 9c2676a4..b123d23d 100644
--- a/src/country_workspace/utils/flex_fields.py
+++ b/src/country_workspace/utils/flex_fields.py
@@ -1,9 +1,15 @@
+from base64 import b64encode
import hashlib
import json
-from typing import TYPE_CHECKING, Generator
+from typing import TYPE_CHECKING, Generator, Literal
+
+from django import forms
+from django.core.files.uploadedfile import UploadedFile
from hope_flex_fields.models import DataChecker
+from country_workspace.contrib.kobo.api.data.helpers import VALUE_FORMAT
+
if TYPE_CHECKING:
from country_workspace.models.base import Validable
@@ -21,3 +27,23 @@ def get_obj_checksum(obj: "Validable") -> str:
if obj.flex_files:
h.update(obj.flex_files[:8192]) # is this enough ?
return h.hexdigest()
+
+
+class Base64ImageInput(forms.ClearableFileInput):
+ template_name = "workspace/base64_image_widget.html"
+
+ def is_initial(self, value: str | None) -> bool:
+ # we need to override this as base method looks for url
+ return bool(value)
+
+
+class Base64ImageField(forms.ImageField):
+ widget = Base64ImageInput
+
+ def clean(self, data: UploadedFile | Literal[False], initial: str | None = None) -> str | None:
+ if cleaned_data := super().clean(data, initial):
+ content = b64encode(cleaned_data.read()).decode()
+ return VALUE_FORMAT.format(mimetype=data.content_type, content=content)
+
+ # if we return cleaned_data here, False will be stored, so we return None explicitly
+ return None
diff --git a/src/country_workspace/versioning/scripts/0004_add_base64_image_field.py b/src/country_workspace/versioning/scripts/0004_add_base64_image_field.py
new file mode 100644
index 00000000..de87c0ad
--- /dev/null
+++ b/src/country_workspace/versioning/scripts/0004_add_base64_image_field.py
@@ -0,0 +1,31 @@
+# Generated by HCW 0.1.0 on 2025 04 15 09:25:45
+from concurrency.utils import fqn
+from hope_flex_fields.models import FieldDefinition
+from hope_flex_fields.registry import field_registry
+from hope_flex_fields.utils import get_kwargs_from_field_class, get_common_attrs
+from packaging.version import Version
+
+from country_workspace.utils.flex_fields import Base64ImageField
+
+_script_for_version = Version("0.1.0")
+
+
+def forward() -> None:
+ field_registry.register(Base64ImageField)
+ FieldDefinition.objects.get_or_create(
+ name=Base64ImageField.__name__,
+ field_type=fqn(Base64ImageField),
+ defaults={"attrs": get_kwargs_from_field_class(Base64ImageField, get_common_attrs())},
+ )
+
+
+def backward() -> None:
+ FieldDefinition.objects.get(
+ name=Base64ImageField.__name__,
+ field_type=fqn(Base64ImageField),
+ ).delete()
+
+
+class Scripts:
+ requires = []
+ operations = [(forward, backward)]
diff --git a/src/country_workspace/workspaces/admin/hh_ind.py b/src/country_workspace/workspaces/admin/hh_ind.py
index 1ed0c746..615119ad 100644
--- a/src/country_workspace/workspaces/admin/hh_ind.py
+++ b/src/country_workspace/workspaces/admin/hh_ind.py
@@ -22,7 +22,7 @@
from ..models import CountryHousehold, CountryIndividual
from .cleaners import actions
from .cleaners.validate import validate_program
-
+from ...utils.flex_fields import Base64ImageField
if TYPE_CHECKING:
from hope_flex_fields.forms import FlexForm
@@ -231,7 +231,7 @@ def _changeform_view(
initials = {}
if request.method == "POST":
if obj:
- form: "FlexForm" = form_class(request.POST, prefix="flex_field", initial=initials)
+ form: "FlexForm" = form_class(request.POST, request.FILES, prefix="flex_field", initial=initials)
form_valid = form.is_valid()
if form_valid or "_save_invalid" in request.POST:
obj.flex_fields = form.cleaned_data
@@ -248,6 +248,7 @@ def _changeform_view(
context["show_save_invalid"] = True
context["checker_form"] = form
context["has_change_permission"] = self.has_change_permission(request)
+ context["has_file_field"] = any(isinstance(field, Base64ImageField) for field in form.fields.values())
return TemplateResponse(request, self.change_form_template, context)
diff --git a/src/country_workspace/workspaces/templates/workspace/base64_image_widget.html b/src/country_workspace/workspaces/templates/workspace/base64_image_widget.html
new file mode 100644
index 00000000..328b7eba
--- /dev/null
+++ b/src/country_workspace/workspaces/templates/workspace/base64_image_widget.html
@@ -0,0 +1,12 @@
+{% if widget.is_initial %}
+ {{ widget.initial_text }}:
+
+ {% if not widget.required %}
+
+
+ {% endif %}
+ {{ widget.input_text }}:
+{% endif %}
+
diff --git a/tests/utils/test_utils_fields.py b/tests/utils/test_utils_fields.py
index 7a150b39..4ad526b3 100644
--- a/tests/utils/test_utils_fields.py
+++ b/tests/utils/test_utils_fields.py
@@ -1,12 +1,17 @@
+from unittest.mock import Mock
+
import pytest
-from pytest_mock import MockFixture
+from django.core.files.uploadedfile import SimpleUploadedFile
+from pytest_mock import MockerFixture
+from country_workspace.contrib.kobo.api.data.helpers import VALUE_FORMAT
from country_workspace.utils.fields import (
clean_field_name,
TO_REMOVE_VALUES,
clean_field_names,
map_fields,
)
+from country_workspace.utils.flex_fields import Base64ImageInput, Base64ImageField
@pytest.mark.parametrize(
@@ -21,7 +26,7 @@ def test_clean_field_name(input_value, expected_output):
assert clean_field_name(input_value) == expected_output
-def test_clean_field_names(mocker: MockFixture) -> None:
+def test_clean_field_names(mocker: MockerFixture) -> None:
clean_field_name_mock = mocker.patch("country_workspace.utils.fields.clean_field_name")
cleaned = clean_field_names({(key := "foo"): "bar"})
@@ -42,3 +47,28 @@ def test_clean_field_names(mocker: MockFixture) -> None:
def test_map_fields(input_fields, expected_output):
result = map_fields(input_fields)
assert result == expected_output
+
+
+@pytest.mark.parametrize("value", [None, "", "test"])
+def test_base64_image_input(value: str | None) -> None:
+ input_ = Base64ImageInput()
+ assert input_.is_initial(value) == bool(value)
+
+
+def test_base64_image_field_file_was_cleared(mocker: MockerFixture) -> None:
+ super_clean_mock = mocker.patch("country_workspace.utils.flex_fields.forms.ImageField.clean")
+ super_clean_mock.return_value = False
+ instance = Mock(spec=Base64ImageField)
+
+ assert Base64ImageField.clean(instance, False) is None
+
+
+def test_base64_image_field_content_is_encoded(mocker: MockerFixture) -> None:
+ super_clean_mock = mocker.patch("country_workspace.utils.flex_fields.forms.ImageField.clean")
+ b64encode_mock = mocker.patch("country_workspace.utils.flex_fields.b64encode")
+ b64encode_mock.return_value.decode.return_value = (data := "decoded")
+ file = SimpleUploadedFile("test.txt", b"test", content_type=(content_type := "text/plain"))
+ super_clean_mock.return_value = file
+ instance = Mock(spec=Base64ImageField)
+
+ assert Base64ImageField.clean(instance, file) == VALUE_FORMAT.format(mimetype=content_type, content=data)
diff --git a/uv.lock b/uv.lock
index d52b63cf..aaa047cf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1159,6 +1159,7 @@ dependencies = [
{ name = "hope-smart-export" },
{ name = "hope-smart-import" },
{ name = "openpyxl" },
+ { name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "python-redis-lock", extra = ["django"] },
{ name = "redis" },
@@ -1251,6 +1252,7 @@ requires-dist = [
{ name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.36" },
{ name = "mkdocstrings-python", marker = "extra == 'docs'" },
{ name = "openpyxl", specifier = ">=3.1.5" },
+ { name = "pillow", specifier = ">=11.2.1" },
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
{ name = "python-redis-lock", extras = ["django"], specifier = ">=4.0.0" },
{ name = "redis" },
@@ -1961,6 +1963,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 },
]
+[[package]]
+name = "pillow"
+version = "11.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 },
+ { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 },
+ { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 },
+ { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 },
+ { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 },
+ { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 },
+ { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 },
+ { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 },
+ { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 },
+ { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 },
+ { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 },
+ { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 },
+ { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 },
+ { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 },
+ { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 },
+ { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 },
+ { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 },
+ { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 },
+ { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 },
+ { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 },
+ { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 },
+ { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 },
+ { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 },
+ { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 },
+ { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 },
+ { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 },
+ { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 },
+ { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 },
+ { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 },
+ { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 },
+ { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 },
+ { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 },
+ { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 },
+]
+
[[package]]
name = "pip"
version = "25.0.1"