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"