From 661cc9d36cc343e0e650449d076cd259e3f3c135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Dubois?= Date: Tue, 7 Apr 2026 15:41:43 +0200 Subject: [PATCH 1/4] feat: allow non-ASCII characters in schedule comments CITIBDC-568 --- CHANGES.rst | 3 +++ .../core/contents/contact/configure.zcml | 1 + .../core/contents/contact/deserializer.py | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/imio/directory/core/contents/contact/deserializer.py diff --git a/CHANGES.rst b/CHANGES.rst index d0b5147..89ccdd2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog 1.2.27 (unreleased) ------------------- +- CITIBDC-568 : Allow non-ASCII characters in schedule comments by overriding DictFieldDeserializer for ISchedule fields. + [remdub] + - Fix CSRF error : Wrong factory! Should be ContactCategoriesVocabularyFactory() instead of ContactCategoriesDe... [boulch] diff --git a/src/imio/directory/core/contents/contact/configure.zcml b/src/imio/directory/core/contents/contact/configure.zcml index be18735..0093d4f 100644 --- a/src/imio/directory/core/contents/contact/configure.zcml +++ b/src/imio/directory/core/contents/contact/configure.zcml @@ -6,6 +6,7 @@ for=".content.IContact plone.restapi.interfaces.IPloneRestapiLayer" /> + diff --git a/src/imio/directory/core/contents/contact/deserializer.py b/src/imio/directory/core/contents/contact/deserializer.py new file mode 100644 index 0000000..21ad0ff --- /dev/null +++ b/src/imio/directory/core/contents/contact/deserializer.py @@ -0,0 +1,26 @@ +from collective.schedulefield.schedule import ISchedule +from plone.dexterity.interfaces import IDexterityContent +from plone.restapi.deserializer.dxfields import DefaultFieldDeserializer +from plone.restapi.interfaces import IFieldDeserializer +from zope.component import adapter +from zope.interface import implementer +from zope.publisher.interfaces.browser import IBrowserRequest + +import json + + +@implementer(IFieldDeserializer) +@adapter(ISchedule, IDexterityContent, IBrowserRequest) +class ScheduleFieldDeserializer(DefaultFieldDeserializer): + """Allow non-ASCII characters (e.g. accented) in schedule comments. + The default DictFieldDeserializer recursively validates inner dict values + against ASCIILine(), rejecting accented characters. Schedule.validate() + already skips the 'comment' key, so we use it directly instead.""" + + def __call__(self, value): + if not value: + return value + if isinstance(value, str): + value = json.loads(value) + self.field.validate(value) + return value From b9f5037aabca7292691346ad44cd4d598f9c6fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Dubois?= Date: Tue, 7 Apr 2026 16:04:59 +0200 Subject: [PATCH 2/4] feat: add tests --- .../core/contents/contact/deserializer.py | 5 +- src/imio/directory/core/tests/test_contact.py | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/imio/directory/core/contents/contact/deserializer.py b/src/imio/directory/core/contents/contact/deserializer.py index 21ad0ff..3e7a25f 100644 --- a/src/imio/directory/core/contents/contact/deserializer.py +++ b/src/imio/directory/core/contents/contact/deserializer.py @@ -21,6 +21,9 @@ def __call__(self, value): if not value: return value if isinstance(value, str): - value = json.loads(value) + try: + value = json.loads(value) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON for schedule field: {e}") from e self.field.validate(value) return value diff --git a/src/imio/directory/core/tests/test_contact.py b/src/imio/directory/core/tests/test_contact.py index d60d8e8..4085484 100644 --- a/src/imio/directory/core/tests/test_contact.py +++ b/src/imio/directory/core/tests/test_contact.py @@ -286,6 +286,83 @@ def test_overall_response_format(self): "items_total property should match actual item count.", ) + def _make_schedule(self, comment=""): + days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + return { + day: { + "morningstart": "09:00" if day not in ("saturday", "sunday") else "", + "morningend": "12:00" if day not in ("saturday", "sunday") else "", + "afternoonstart": "13:00" if day not in ("saturday", "sunday") else "", + "afternoonend": "17:00" if day not in ("saturday", "sunday") else "", + "comment": comment if day == "monday" else "", + } + for day in days + } + + def test_schedule_patch_with_non_ascii_comment(self): + contact = api.content.create( + container=self.entity, + type="imio.directory.Contact", + title="contact", + ) + transaction.commit() + comment = "uniquement sur rendez-vous à partir de 14h (été)" + response = self.api_session.patch( + contact.absolute_url(), + json={"schedule": self._make_schedule(comment)}, + ) + self.assertEqual(response.status_code, 204) + result = self.api_session.get(contact.absolute_url()).json() + self.assertEqual(result["schedule"]["monday"]["comment"], comment) + + def test_schedule_patch_with_ascii_comment(self): + contact = api.content.create( + container=self.entity, + type="imio.directory.Contact", + title="contact", + ) + transaction.commit() + comment = "by appointment only" + response = self.api_session.patch( + contact.absolute_url(), + json={"schedule": self._make_schedule(comment)}, + ) + self.assertEqual(response.status_code, 204) + result = self.api_session.get(contact.absolute_url()).json() + self.assertEqual(result["schedule"]["monday"]["comment"], comment) + + def test_schedule_patch_with_empty_value(self): + contact = api.content.create( + container=self.entity, + type="imio.directory.Contact", + title="contact", + ) + transaction.commit() + response = self.api_session.patch( + contact.absolute_url(), + json={"schedule": {}}, + ) + self.assertEqual(response.status_code, 204) + + def test_schedule_patch_as_json_string(self): + import json + + contact = api.content.create( + container=self.entity, + type="imio.directory.Contact", + title="contact", + ) + transaction.commit() + comment = "réunion hebdomadaire" + schedule = self._make_schedule(comment) + response = self.api_session.patch( + contact.absolute_url(), + json={"schedule": json.dumps(schedule)}, + ) + self.assertEqual(response.status_code, 204) + result = self.api_session.get(contact.absolute_url()).json() + self.assertEqual(result["schedule"]["monday"]["comment"], comment) + def test_subscriber_to_select_current_entity(self): contact = api.content.create( container=self.entity, From 465c51eddc70f0155f0da7d7ec317c51ccb2d250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Dubois?= Date: Tue, 7 Apr 2026 16:06:47 +0200 Subject: [PATCH 3/4] fix: black --- src/imio/directory/core/tests/test_contact.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/imio/directory/core/tests/test_contact.py b/src/imio/directory/core/tests/test_contact.py index 4085484..9120220 100644 --- a/src/imio/directory/core/tests/test_contact.py +++ b/src/imio/directory/core/tests/test_contact.py @@ -287,7 +287,15 @@ def test_overall_response_format(self): ) def _make_schedule(self, comment=""): - days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + days = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] return { day: { "morningstart": "09:00" if day not in ("saturday", "sunday") else "", From 966ade0baa6093368a06f508ac88767e2ae0cf01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Dubois?= Date: Wed, 8 Apr 2026 10:53:50 +0200 Subject: [PATCH 4/4] feat: add test for non ascii time --- src/imio/directory/core/tests/test_contact.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/imio/directory/core/tests/test_contact.py b/src/imio/directory/core/tests/test_contact.py index 9120220..4eea1ae 100644 --- a/src/imio/directory/core/tests/test_contact.py +++ b/src/imio/directory/core/tests/test_contact.py @@ -371,6 +371,21 @@ def test_schedule_patch_as_json_string(self): result = self.api_session.get(contact.absolute_url()).json() self.assertEqual(result["schedule"]["monday"]["comment"], comment) + def test_schedule_patch_with_non_ascii_time_field(self): + contact = api.content.create( + container=self.entity, + type="imio.directory.Contact", + title="contact", + ) + transaction.commit() + schedule = self._make_schedule() + schedule["monday"]["afternoonend"] = "17:é0" + response = self.api_session.patch( + contact.absolute_url(), + json={"schedule": schedule}, + ) + self.assertEqual(response.status_code, 400) + def test_subscriber_to_select_current_entity(self): contact = api.content.create( container=self.entity,