From edc0986f63fbb721cf4afedd4aece915496ae7ff Mon Sep 17 00:00:00 2001
From: Daniel <d.hornung@indiscale.com>
Date: Wed, 8 Nov 2023 12:33:26 +0100
Subject: [PATCH] ENH: jsex: multiple choice option for "do_not_create" list
 props.

---
 src/caosadvancedtools/json_schema_exporter.py | 88 ++++++++++++++-----
 unittests/test_json_schema_exporter.py        | 78 ++++++++++++++--
 2 files changed, 133 insertions(+), 33 deletions(-)

diff --git a/src/caosadvancedtools/json_schema_exporter.py b/src/caosadvancedtools/json_schema_exporter.py
index c836d56c..5c9362fd 100644
--- a/src/caosadvancedtools/json_schema_exporter.py
+++ b/src/caosadvancedtools/json_schema_exporter.py
@@ -25,7 +25,7 @@ The scope of this json schema is the automatic generation of user interfaces.
 """
 
 from collections import OrderedDict
-from typing import Any, Dict, Iterable, List, Optional, Union
+from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
 
 import linkahead as db
 from linkahead.common.datatype import get_list_datatype, is_list_datatype
@@ -43,6 +43,7 @@ class JsonSchemaExporter:
                  do_not_create: List[str] = None,
                  do_not_retrieve: List[str] = None,
                  no_remote: bool = False,
+                 multiple_choice: List[str] = None,
                  ):
         """Set up a JsonSchemaExporter, which can then be applied on RecordTypes.
 
@@ -75,6 +76,10 @@ class JsonSchemaExporter:
             parameter, the behavior is undefined.
         no_remote : bool
             If True, do not attempt to connect to a LinkAhead server at all. Default is False.
+        multiple_choice : list[str], optional
+            A list of reference Property names which shall be denoted as multiple choice properties.
+            This means that each option in this property may be selected at most once.  This is not
+            implemented yet if the Property is not in ``do_not_create`` as well.
         """
         if not additional_options_for_text_props:
             additional_options_for_text_props = {}
@@ -82,6 +87,8 @@ class JsonSchemaExporter:
             do_not_create = []
         if not do_not_retrieve:
             do_not_retrieve = []
+        if not multiple_choice:
+            multiple_choice = []
 
         self._additional_properties = additional_properties
         self._name_property_for_new_records = name_property_for_new_records
@@ -91,6 +98,8 @@ class JsonSchemaExporter:
         self._do_not_create = do_not_create
         self._do_not_retrieve = do_not_retrieve
         self._no_remote = no_remote
+        self._multiple_choice = multiple_choice
+        self._rjsf_uischema: dict = {}
 
     @staticmethod
     def _make_required_list(rt: db.RecordType):
@@ -106,15 +115,15 @@ class JsonSchemaExporter:
 
         return required_list
 
-    def _make_segment_from_prop(self, prop: db.Property):
-        """Return the JSON Schema segment for the given property
+    def _make_segment_from_prop(self, prop: db.Property) -> Tuple[OrderedDict, dict]:
+        """Return the JSON Schema and ui schema segments for the given property.
 
         Parameters
         ----------
         prop : db.Property
             The property to be transformed.
         """
-
+        ui_schema: dict = {}
         if prop.datatype == db.TEXT or prop.datatype == db.DATETIME:
             text_format = None
             text_pattern = None
@@ -131,7 +140,7 @@ class JsonSchemaExporter:
                 # options list.
                 text_format = ["date", "date-time"]
 
-            return self._make_text_property(prop.description, text_format, text_pattern)
+            return self._make_text_property(prop.description, text_format, text_pattern), ui_schema
 
         json_prop = OrderedDict()
         if prop.description:
@@ -154,13 +163,21 @@ class JsonSchemaExporter:
             json_prop["type"] = "array"
             list_element_prop = db.Property(
                 name=prop.name, datatype=get_list_datatype(prop.datatype, strict=True))
-            json_prop["items"] = self._make_segment_from_prop(list_element_prop)
+            json_prop["items"], inner_ui_schema = self._make_segment_from_prop(list_element_prop)
+            if prop.name in self._multiple_choice and prop.name in self._do_not_create:
+                json_prop["uniqueItems"] = True
+                ui_schema["ui:widget"] = "checkboxes"
+                ui_schema["ui:options"] = {"inline": True}
+            if inner_ui_schema:
+                ui_schema["items"] = inner_ui_schema
         elif prop.is_reference():
             if prop.datatype == db.REFERENCE:
                 # No Record creation since no RT is specified and we don't know what
                 # schema to use, so only enum of all Records and all Files.
                 values = self._retrieve_enum_values("RECORD") + self._retrieve_enum_values("FILE")
                 json_prop["enum"] = values
+                if prop.name in self._multiple_choice:
+                    json_prop["uniqueItems"] = True
             elif prop.datatype == db.FILE:
                 json_prop["type"] = "string"
                 json_prop["format"] = "data-url"
@@ -181,7 +198,9 @@ class JsonSchemaExporter:
                     else:
                         rt = db.execute_query(f"FIND RECORDTYPE WITH name='{prop_name}'",
                                               unique=True)
-                    subschema = self._make_segment_from_recordtype(rt)
+                    subschema, ui_schema = self._make_segment_from_recordtype(rt)
+                    # if inner_ui_schema:
+                    #     ui_schema = inner_ui_schema
                     if values:
                         subschema["title"] = "Create new"
                         json_prop["oneOf"] = [
@@ -198,7 +217,7 @@ class JsonSchemaExporter:
             raise ValueError(
                 f"Unknown or no property datatype. Property {prop.name} with type {prop.datatype}")
 
-        return json_prop
+        return json_prop, ui_schema
 
     @staticmethod
     def _make_text_property(description="", text_format=None, text_pattern=None):
@@ -245,16 +264,20 @@ class JsonSchemaExporter:
 
         return vals
 
-    def _make_segment_from_recordtype(self, rt: db.RecordType):
-        """Return a Json schema segment for the given RecordType.
+    def _make_segment_from_recordtype(self, rt: db.RecordType) -> Tuple[OrderedDict, dict]:
+        """Return Json schema and uischema segments for the given RecordType.
         """
-        schema: dict[str, Any] = {
+        schema: OrderedDict[str, Any] = OrderedDict({
             "type": "object"
-        }
+        })
+        ui_schema = {}
 
         schema["required"] = self._make_required_list(rt)
         schema["additionalProperties"] = self._additional_properties
 
+        if rt.name:
+            schema["title"] = rt.name
+
         props = OrderedDict()
         if self._name_property_for_new_records:
             props["name"] = self._make_text_property("The name of the Record to be created")
@@ -269,13 +292,15 @@ class JsonSchemaExporter:
                     "Creating a schema for multi-properties is not specified. "
                     f"Property {prop.name} occurs more than once."
                 )
-            props[prop.name] = self._make_segment_from_prop(prop)
+            props[prop.name], inner_ui_schema = self._make_segment_from_prop(prop)
+            if inner_ui_schema:
+                ui_schema[prop.name] = inner_ui_schema
 
         schema["properties"] = props
 
-        return schema
+        return schema, ui_schema
 
-    def recordtype_to_json_schema(self, rt: db.RecordType):
+    def recordtype_to_json_schema(self, rt: db.RecordType, rjsf_uischema: Optional[dict] = None):
         """Create a jsonschema from a given RecordType that can be used, e.g., to
         validate a json specifying a record of the given type.
 
@@ -283,6 +308,9 @@ class JsonSchemaExporter:
         ----------
         rt : RecordType
             The RecordType from which a json schema will be created.
+        rjsf_uischema : dict, optional
+            If given, uiSchema definitions for react-jsonschema-forms will be appended to this dict
+            at ``[rt.name]``.
 
         Returns
         -------
@@ -292,10 +320,13 @@ class JsonSchemaExporter:
         if rt is None:
             raise ValueError(
                 "recordtype_to_json_schema(...) cannot be called with a `None` RecordType.")
-        schema = self._make_segment_from_recordtype(rt)
-        schema["$schema"] = "https://json-schema.org/draft/2019-09/schema"
-        if rt.name:
-            schema["title"] = rt.name
+        if rjsf_uischema is None:
+            rjsf_uischema = {}
+
+        schema, inner_uischema = self._make_segment_from_recordtype(rt)
+        if inner_uischema:
+            rjsf_uischema[rt.name] = inner_uischema
+        schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
         if rt.description:
             schema["description"] = rt.description
 
@@ -310,6 +341,8 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T
                               do_not_create: List[str] = None,
                               do_not_retrieve: List[str] = None,
                               no_remote: bool = False,
+                              multiple_choice: List[str] = None,
+                              rjsf_uischema: Optional[dict] = None,
                               ):
     """Create a jsonschema from a given RecordType that can be used, e.g., to
     validate a json specifying a record of the given type.
@@ -338,16 +371,22 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T
         description of the corresponding schema entry. If set to false, an
         additional `unit` key is added to the schema itself which is purely
         annotational and ignored, e.g., in validation. Default is True.
-    do_not_create : list[str]
+    do_not_create : list[str], optional
         A list of RedcordType names, for which there should be no option
         to create them.  Instead, only the choice of existing elements should
         be given.
-    do_not_retrieve : list[str]
+    do_not_retrieve : list[str], optional
         A list of RedcordType names, for which no Records shall be retrieved.  Instead, only an
         object description should be given.  If this list overlaps with the `do_not_create`
         parameter, the behavior is undefined.
-    no_remote : bool
+    no_remote : bool, optional
         If True, do not attempt to connect to a LinkAhead server at all.  Default is False.
+    multiple_choice : list[str], optional
+        A list of reference Property names which shall be denoted as multiple choice properties.
+        This means that each option in this property may be selected at most once.  This is not
+        implemented yet if the Property is not in ``do_not_create`` as well.
+    rjsf_uischema : dict, optional
+        If given, uiSchema definitions for react-jsonschema-forms will be appended to this dict.
 
     Returns
     -------
@@ -365,8 +404,9 @@ def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = T
         do_not_create=do_not_create,
         do_not_retrieve=do_not_retrieve,
         no_remote=no_remote,
+        multiple_choice=multiple_choice,
     )
-    return exporter.recordtype_to_json_schema(rt)
+    return exporter.recordtype_to_json_schema(rt, rjsf_uischema=rjsf_uischema)
 
 
 def make_array(schema: dict) -> dict:
@@ -397,7 +437,7 @@ out : dict
     result = {
         "type": "array",
         "items": schema,
-        "$schema": "https://json-schema.org/draft/2019-09/schema",
+        "$schema": "https://json-schema.org/draft/2020-12/schema",
     }
     return result
 
diff --git a/unittests/test_json_schema_exporter.py b/unittests/test_json_schema_exporter.py
index e2772f55..8fe43bda 100644
--- a/unittests/test_json_schema_exporter.py
+++ b/unittests/test_json_schema_exporter.py
@@ -81,6 +81,19 @@ def _mock_execute_query(query_string, unique=False, **kwargs):
         return referencing_type_records  # wrong types, but who cares for the test?
     elif query_string == "FIND RECORDTYPE WITH name='RT1'" and unique is True:
         return RT1
+    elif query_string == "FIND RECORDTYPE WITH name='RT21'" and unique is True:
+        model_str = """
+RT1:
+RT21:
+  obligatory_properties:
+    RT1:
+      datatype: LIST<RT1>
+RT3:
+  obligatory_properties:
+    RT21:
+    """
+        model = parse_model_from_string(model_str)
+        return model.get_deep("RT21")
     elif query_string == "SELECT name, id FROM RECORD":
         return all_records
     elif query_string == "SELECT name, id FROM FILE":
@@ -602,6 +615,7 @@ RT2:
     "some_date"
   ],
   "additionalProperties": true,
+  "title": "RT1",
   "properties": {
     "some_date": {
       "description": "Just some date",
@@ -617,8 +631,7 @@ RT2:
       ]
     }
   },
-  "$schema": "https://json-schema.org/draft/2019-09/schema",
-  "title": "RT1",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
   "description": "some description"
 }"""
     # Second test: with reference
@@ -630,6 +643,7 @@ RT2:
     "RT1"
   ],
   "additionalProperties": true,
+  "title": "RT2",
   "properties": {
     "RT1": {
       "description": "some description",
@@ -647,6 +661,7 @@ RT2:
             "some_date"
           ],
           "additionalProperties": true,
+          "title": "Create new",
           "properties": {
             "some_date": {
               "description": "Just some date",
@@ -661,14 +676,12 @@ RT2:
                 }
               ]
             }
-          },
-          "title": "Create new"
+          }
         }
       ]
     }
   },
-  "$schema": "https://json-schema.org/draft/2019-09/schema",
-  "title": "RT2"
+  "$schema": "https://json-schema.org/draft/2020-12/schema"
 }"""
 
     # Third test: Reference prop shall be only existing references, no option to create new ones.
@@ -679,6 +692,7 @@ RT2:
     "RT1"
   ],
   "additionalProperties": true,
+  "title": "RT2",
   "properties": {
     "RT1": {
       "description": "some description",
@@ -688,8 +702,7 @@ RT2:
       ]
     }
   },
-  "$schema": "https://json-schema.org/draft/2019-09/schema",
-  "title": "RT2"
+  "$schema": "https://json-schema.org/draft/2020-12/schema"
 }"""
 
 
@@ -820,6 +833,53 @@ RT3:
     assert (schema_noexist_noremote["properties"]["NoRecords"].get("properties")
             == OrderedDict([('some_text', {'type': 'string'})]))
 
-    schema_noexist_noretrieve = rtjs(model.get_deep("RT2"), do_not_retrieve=["RT1"])
+    uischema = {}
+    schema_noexist_noretrieve = rtjs(model.get_deep("RT2"), do_not_retrieve=["RT1"],
+                                     rjsf_uischema=uischema)
     assert schema_noexist_noretrieve["properties"]["RT1"].get("type") == "object"
     assert "some_date" in schema_noexist_noretrieve["properties"]["RT1"].get("properties")
+    assert not uischema
+
+
+@patch("linkahead.execute_query", new=Mock(side_effect=_mock_execute_query))
+def test_multiple_choice():
+    """Multiple choice is mostyly a matter of UI."""
+    model_str = """
+RT1:
+RT21:
+  obligatory_properties:
+    RT1:
+      datatype: LIST<RT1>
+RT3:
+  obligatory_properties:
+    RT21:
+RT4:
+  obligatory_properties:
+    RT21:
+      datatype: LIST<RT21>
+    """
+    model = parse_model_from_string(model_str)
+    # generate a multiple choice, in first level
+    uischema = {}
+    schema = rtjs(model.get_deep("RT21"), additional_properties=False, do_not_create=["RT1"],
+                  multiple_choice=["RT1"], rjsf_uischema=uischema)
+    assert schema["properties"]["RT1"]["uniqueItems"] is True
+    assert (str(uischema["RT21"]) ==
+            "{'RT1': {'ui:widget': 'checkboxes', 'ui:options': {'inline': True}}}")
+
+    # second level
+    uischema = {}
+    schema = rtjs(model.get_deep("RT3"), additional_properties=False, do_not_create=["RT1"],
+                  multiple_choice=["RT1"], rjsf_uischema=uischema)
+    assert schema["properties"]["RT21"]["properties"]["RT1"]["uniqueItems"] is True
+    assert (str(uischema["RT3"]) ==
+            "{'RT21': {'RT1': {'ui:widget': 'checkboxes', 'ui:options': {'inline': True}}}}")
+
+    # second level with lists
+    uischema = {}
+    schema = rtjs(model.get_deep("RT4"), additional_properties=False, do_not_create=["RT1"],
+                  multiple_choice=["RT1"], rjsf_uischema=uischema)
+    assert schema["properties"]["RT21"]["items"]["properties"]["RT1"]["uniqueItems"] is True
+    assert (str(uischema["RT4"]) ==
+            "{'RT21': {'items': {'RT1': {'ui:widget': 'checkboxes', "
+            "'ui:options': {'inline': True}}}}}")
-- 
GitLab