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,