Repository: XLSForm/pyxform
Pull request #766 (2025-06-03 16:26:12)

765: odk:client-editable submission config
Closes #765

#### Why is this the best possible solution? Were any other approaches considered?

Similar to auto_send / auto_delete, but requested to allow more true/false-y values than just 'true' accepted for those.

#### What are the regression risks?

Unlikely problem but if a user had a setting column called 'client-editable' used for something else like documentation, now it does something to the XForm if it contains an alias of `true`.

#### Does this change require updates to documentation? If so, please file an issue [here](https://github.com/XLSForm/xlsform.github.io) and include the link below.

Filed https://github.com/getodk/xforms-spec/issues/328 and https://github.com/getodk/docs/issues/1957

#### Before submitting this PR, please make sure you have:
- [x] included test cases for core behavior and edge cases in `tests`
- [x] run `python -m unittest` and verified all tests pass
- [x] run `ruff format pyxform tests` and `ruff check pyxform tests` to lint code
- [x] verified that any code or assets from external sources are properly credited in comments


Patch:
diff --git a/pyxform/constants.py b/pyxform/constants.py
index 793d1e0..cb568a6 100644
--- a/pyxform/constants.py
+++ b/pyxform/constants.py
@@ -34,6 +34,7 @@ PUBLIC_KEY = "public_key"
 SUBMISSION_URL = "submission_url"
 AUTO_SEND = "auto_send"
 AUTO_DELETE = "auto_delete"
+CLIENT_EDITABLE = "client_editable"
 DEFAULT_FORM_NAME = "data"
 DEFAULT_LANGUAGE_KEY = "default_language"
 DEFAULT_LANGUAGE_VALUE = "default"
diff --git a/pyxform/survey.py b/pyxform/survey.py
index 12607ed..bad9b2a 100644
--- a/pyxform/survey.py
+++ b/pyxform/survey.py
@@ -205,6 +205,7 @@ SURVEY_EXTRA_FIELDS = (
     "title",
     "version",
     constants.ALLOW_CHOICE_DUPLICATES,
+    constants.CLIENT_EDITABLE,
     constants.COMPACT_DELIMITER,
     constants.COMPACT_PREFIX,
     constants.ENTITY_FEATURES,
@@ -251,6 +252,7 @@ class Survey(Section):
         self.auto_delete: str | None = None
         self.auto_send: str | None = None
         self.clean_text_values: bool = False
+        self.client_editable: bool = False
         self.instance_xmlns: str | None = None
         self.namespaces: str | None = None
         self.omit_instanceID: bool = False
@@ -692,7 +694,15 @@ class Survey(Section):
             node("instance", self.xml_instance()),
         )
 
-        if self.submission_url or self.public_key or self.auto_send or self.auto_delete:
+        if any(
+            (
+                self.submission_url,
+                self.public_key,
+                self.auto_send,
+                self.auto_delete,
+                self.client_editable,
+            )
+        ):
             submission_attrs = {}
             if self.submission_url:
                 submission_attrs["action"] = self.submission_url
@@ -703,6 +713,8 @@ class Survey(Section):
                 submission_attrs["orx:auto-send"] = self.auto_send
             if self.auto_delete:
                 submission_attrs["orx:auto-delete"] = self.auto_delete
+            if self.client_editable:
+                submission_attrs["odk:client-editable"] = "true"
             submission_node = node("submission", **submission_attrs)
             model_children.insert(0, submission_node)
 
diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py
index f5cc62b..93ab1c3 100644
--- a/pyxform/xls2json.py
+++ b/pyxform/xls2json.py
@@ -364,6 +364,10 @@ def workbook_to_json(
         settings["add_none_option"] = aliases.yes_no.get(
             settings.get("add_none_option", "no"), False
         )
+    if constants.CLIENT_EDITABLE in settings:
+        settings[constants.CLIENT_EDITABLE] = aliases.yes_no.get(
+            settings.get(constants.CLIENT_EDITABLE, "no"), False
+        )
 
     # Here we create our json dict root with default settings:
     id_string = settings.get(


Test patch:
diff --git a/tests/test_settings.py b/tests/test_settings.py
index b74484b..a088489 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -291,3 +291,71 @@ class TestNamespaces(PyxformTestCase):
                 """,
             ],
         )
+
+    def test_client_editable_setting__active(self):
+        """Should find the odk:client-editable attribute in the submission config."""
+        # Set to an alias of True.
+        md1 = """
+        | settings |
+        |          | client_editable |
+        |          | yes             |
+        | survey |       |      |       |
+        |        | type  | name | label |
+        |        | text  | q1   | hello |
+        """
+        # Set the same way as another submission setting (e.g. auto_send).
+        md2 = """
+        | settings |
+        |          | client_editable |
+        |          | true            |
+        | survey |       |      |       |
+        |        | type  | name | label |
+        |        | text  | q1   | hello |
+        """
+        for md in (md1, md2):
+            self.assertPyxformXform(
+                md=md,
+                xml__xpath_match=[
+                    """/h:html/h:head/x:model/x:submission[@odk:client-editable = 'true']""",
+                ],
+            )
+
+    def test_client_editable_setting__inactive(self):
+        """Should not find the odk:client-editable attribute in the submission config."""
+        # Set to an alias of False.
+        md1 = """
+        | settings |
+        |          | client_editable |
+        |          | no              |
+        | survey |       |      |       |
+        |        | type  | name | label |
+        |        | text  | q1   | hello |
+        """
+        # Not set.
+        md2 = """
+        | survey |       |      |       |
+        |        | type  | name | label |
+        |        | text  | q1   | hello |
+        """
+        for md in (md1, md2):
+            self.assertPyxformXform(
+                md=md,
+                xml__xpath_match=[
+                    """/h:html/h:head/x:model[not(./x:submission/@odk:client-editable)]""",
+                ],
+            )
+        # Set to false, with other setting that triggers `submission` element.
+        md3 = """
+        | settings |
+        |          | client_editable | auto_send |
+        |          | false           | true      |
+        | survey |       |      |       |
+        |        | type  | name | label |
+        |        | text  | q1   | hello |
+        """
+        self.assertPyxformXform(
+            md=md3,
+            xml__xpath_match=[
+                """/h:html/h:head/x:model/x:submission[not(@odk:client-editable)]""",
+            ],
+        )