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..3e7a25f --- /dev/null +++ b/src/imio/directory/core/contents/contact/deserializer.py @@ -0,0 +1,29 @@ +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): + 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..4eea1ae 100644 --- a/src/imio/directory/core/tests/test_contact.py +++ b/src/imio/directory/core/tests/test_contact.py @@ -286,6 +286,106 @@ 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_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,