Repository: FlexMeasures/flexmeasures
Pull request #1125 (2024-07-17 01:46:30)

Add support for adding titles to asset page graphs
## Description

To allow for custom graph titles for sensors input by the user by modifying the `sensors_to_show` attribute in the attributes section.

Files changed:

- `flexmeasures/data/models/generic_assets.py`:
    - Modified `sensors_to_show` attribute to accept dictionaries with `title` and `sensor` keys or `sensors`.
    - Added logic to handle new format and populate `sensors_to_show` with `{'title': 'sensor_title', 'sensor': sensor_id}` or `{'title': 'sensor_title', 'sensors': List[sensor_ids]}`.
- `flexmeasures/data/schema_generic_assets.py`:
    - Refactored `GenericAssetSchema` to create seperate `SensorsToShowSchema`.
    - Added checks to validate the format of `title` and `sensor` or `sensors` fields in the dictionary.
- `flexmeasures/data/models/charts/belief_charts.py`:
    - Modified `chart_for_multiple_sensors` function to handle the new format.
    - Added support for custom titles in the graph rows.
- `flexmeasures/utils/coding_utils.py`:
    - Modified `flatten_unique` function to handle the new format and extract sensor IDs from dictionaries with `title` and `sensor` or `sensors` keys.

## Look & Feel

When `{'title': 'sensor_title', 'sensor': sensor_id}`  or  `{'title': 'sensor_title', 'sensors': list[sensor_ids]}` is added to `sensors_to_show`, this will set the custom title for the graph of that sensor.

## How to test

Manually test different JSON attributes in the edit asset sidebar. 
Verify that custom titles are correctly applied to the corresponding sensor graphs.

## Further Improvements

[] move the parsing functionality from flatten_unique to SensorsToShowSchema.
[] Add test case.
[] Add documentation and update the change-log for this feature.


## Related Items
 closes  #1119
---

- [x] I agree to contribute to the project under the Apache 2 License.
- [x] To the best of my knowledge, the proposed patch is not based on code under GPL or other license that is incompatible with FlexMeasures.


Patch:
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index 4ed63215..f7e390df 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -9,6 +9,7 @@ v0.23.0 | August XX, 2024
 
 New features
 -------------
+* Added support for adding custom titles the graphs on the asset page. This works via an extension to the sensors_to_show format. [see `PR #1125 <https://github.com/FlexMeasures/flexmeasures/pull/1125>`_]
 * New chart type on sensor page: histogram [see `PR #1143 <https://github.com/FlexMeasures/flexmeasures/pull/1143>`_]
 * Add basic sensor info to sensor page [see `PR #1115 <https://github.com/FlexMeasures/flexmeasures/pull/1115>`_]
 * Add `Statistics` table on the sensor page and also add `api/v3_0/sensors/<id>/stats` endpoint to get sensor statistics [see `PR #1116 <https://github.com/FlexMeasures/flexmeasures/pull/1116>`_]
diff --git a/documentation/views/asset-data.rst b/documentation/views/asset-data.rst
index 6a92279a..43dd8618 100644
--- a/documentation/views/asset-data.rst
+++ b/documentation/views/asset-data.rst
@@ -7,10 +7,9 @@ Assets & sensor data
 Asset page
 ------------
 
-The asset page allows to see data from the asset's sensors, and also to edit attributes of the asset, like its location.
-Other attributes are stored as a JSON string, which can be edited here as well.
-This is meant for meta information that may be used to customize views or functionality, e.g. by plugins.
-This includes the possibility to specify which sensors the asset page should show. For instance, here we include a price sensor from a public asset, by setting ``{"sensor_to_show": [3, 2]}`` (sensor 3 on top, followed by sensor 2 below).
+The asset page allows to plot data from the asset's sensors, show sensors and child assets and also to edit attributes of the asset, like its location.
+
+For instance, in the picture below we include a price sensor from a public asset, then plot the asset's only sensor below that.
 
 
 .. image:: https://github.com/FlexMeasures/screenshots/raw/main/screenshot_asset.png
@@ -20,6 +19,24 @@ This includes the possibility to specify which sensors the asset page should sho
 |
 |
 
+
+The asset page as data dashboard
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The data charts are maybe the most interesting feature - turning it into a data dashboard. When the most interesting sensors are shown, the replay button on the right creates a very meaningful dynamic insight!
+
+With the attribute `sensors_to_show` one can specify from which sensors the asset page should show data. In the example above, this happened by setting ``{"sensor_to_show": [3, 2]}`` (sensor 3 on top, followed by sensor 2 below).
+
+It is also possible to overlay data for multiple sensors within one plot, by setting the `sensors_to_show` attribute to a nested list. For example, ``{"sensor_to_show": [3, [2, 4]]}`` would show the data for sensor 4 laid over the data for sensor 2.
+While it is possible to show an arbitrary number of sensors this way, we recommend showing only the most crucial ones for faster loading, less page scrolling, and generally, a quick grasp of what the asset is up to.
+
+Finally, it is possible to set custom titles for sensor graphs, by setting within `sensors_to_show` a dictionary with a title and sensor or sensors. For example, ``{"title": "Outdoor Temperature", "sensor": 1}`` or ``{"title": "Energy Demand", "sensors": [2, 3]}`` will display the specified title for the corresponding sensor data.
+
+
+
+Status page
+^^^^^^^^^^^^
+
 For each asset, you can also visit a status page to see if your data connectivity and recent jobs are okay. This is how data connectivity status looks like on the building asset from our tutorial:
 
 .. image:: https://github.com/FlexMeasures/screenshots/raw/main/tut/toy-schedule/screenshot_building_status.png
@@ -35,13 +52,6 @@ This is how the audit log looks for the history of actions taken on an asset:
     :align: center
 ..    :scale: 40%
 
-|
-|
-
-.. note:: It is possible to overlay data for multiple sensors, by setting the `sensors_to_show` attribute to a nested list. For example, ``{"sensor_to_show": [3, [2, 4]]}`` would show the data for sensor 4 laid over the data for sensor 2.
-.. note:: While it is possible to show an arbitrary number of sensors this way, we recommend showing only the most crucial ones for faster loading, less page scrolling, and generally, a quick grasp of what the asset is up to.
-.. note:: Asset attributes can be edited through the CLI as well, with the CLI command ``flexmeasures edit attribute``.
-
 
 Sensor page
 -------------
diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py
index 51a3992b..e4e1015e 100644
--- a/flexmeasures/api/v3_0/assets.py
+++ b/flexmeasures/api/v3_0/assets.py
@@ -354,12 +354,10 @@ class AssetAPI(FlaskView):
                 for attr_key, attr_value in v.items():
                     if current_attributes.get(attr_key) != attr_value:
                         audit_log_data.append(
-                            f"Attribute name: {attr_key}, Old value: {current_attributes.get(attr_key)}, New value: {attr_value}"
+                            f"Attr: {attr_key}, From: {current_attributes.get(attr_key)}, To: {attr_value}"
                         )
                 continue
-            audit_log_data.append(
-                f"Field name: {k}, Old value: {getattr(db_asset, k)}, New value: {v}"
-            )
+            audit_log_data.append(f"Field: {k}, From: {getattr(db_asset, k)}, To: {v}")
         audit_log_event = f"Updated asset '{db_asset.name}': {db_asset.id} fields: {'; '.join(audit_log_data)}"
         AssetAuditLog.add_record(db_asset, audit_log_event)
 
diff --git a/flexmeasures/data/models/audit_log.py b/flexmeasures/data/models/audit_log.py
index 41768f7b..26159d1a 100644
--- a/flexmeasures/data/models/audit_log.py
+++ b/flexmeasures/data/models/audit_log.py
@@ -147,11 +147,13 @@ class AssetAuditLog(db.Model, AuthModelMixin):
         else:
             event = f"Updated asset '{asset_or_sensor.name}': {asset_or_sensor.id} "
             affected_asset_id = asset_or_sensor.id
-        event += f"attribute '{attribute_key}' to {attribute_value} from {old_value}"
+        event += f"Attr '{attribute_key}' To {attribute_value} From {old_value}"
 
         audit_log = cls(
             event_datetime=server_now(),
-            event=event,
+            event=truncate_string(
+                event, 250
+            ),  # we truncate the event string to 250 characters by adding ellipses in the middle
             active_user_id=current_user_id,
             active_user_name=current_user_name,
             affected_asset_id=affected_asset_id,
@@ -170,11 +172,57 @@ class AssetAuditLog(db.Model, AuthModelMixin):
         :param event: event to log
         """
         current_user_id, current_user_name = get_current_user_id_name()
+
         audit_log = AssetAuditLog(
             event_datetime=server_now(),
-            event=event,
+            event=truncate_string(
+                event, 250
+            ),  # we truncate the event string to 250 characters by adding ellipses in the middle
             active_user_id=current_user_id,
             active_user_name=current_user_name,
             affected_asset_id=asset.id,
         )
         db.session.add(audit_log)
+
+
+def truncate_event(
+    event: str, attr_key: str, old_value: str, new_value: str, max_length: int = 250
+) -> str:
+    """
+    Truncate the event string and add ellipses if it exceeds max_length.
+
+    Args:
+        event (str): The event message to be truncated.
+        attr_key (str): The attribute key related to the change.
+        old_value (str): The old value of the attribute.
+        new_value (str): The new value of the attribute.
+        max_length (int): The maximum length for the truncated event string.
+
+    Notes:
+        The default max_length of 250 characters helps ensure the entire log entry stays within a 255-character limit.
+        The 60-character limit for old_value and new_value ensures that the change_summary remains readable while fitting within it's maximum allowed length.
+        This choice is based on an assumption that other parts of the log message will not exceed 130 characters combined.
+    """
+    # Truncate values only if event length exceeds max_length
+    if len(event) > max_length:
+        truncated_new_value = truncate_string(str(new_value), 60)
+        truncated_old_value = truncate_string(str(old_value), 60)
+    else:
+        truncated_new_value = new_value
+        truncated_old_value = old_value
+
+    # Construct the log entry
+    change_summary = (
+        f"Attr: {attr_key}, From: {truncated_old_value}, To: {truncated_new_value}"
+    )
+
+    # Ensure the change summary fits within the maximum length
+    return truncate_string(change_summary, max_length)
+
+
+def truncate_string(value: str, max_length: int) -> str:
+    """Truncate a string and add ellipses in the middle if it exceeds max_length."""
+    if len(value) <= max_length:
+        return value
+    half_length = (max_length - 5) // 2
+    return f"{value[:half_length]} ... {value[-half_length:]}"
diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py
index 43381072..47767112 100644
--- a/flexmeasures/data/models/charts/belief_charts.py
+++ b/flexmeasures/data/models/charts/belief_charts.py
@@ -5,7 +5,6 @@ from datetime import datetime, timedelta
 from flexmeasures.data.models.charts.defaults import FIELD_DEFINITIONS, REPLAY_RULER
 from flexmeasures.utils.flexmeasures_inflection import (
     capitalize,
-    join_words_into_a_list,
 )
 from flexmeasures.utils.coding_utils import flatten_unique
 from flexmeasures.utils.unit_utils import (
@@ -477,7 +476,7 @@ def create_fall_dst_transition_layer(
 
 
 def chart_for_multiple_sensors(
-    sensors_to_show: list["Sensor", list["Sensor"]],  # noqa F821
+    sensors_to_show: list["Sensor" | list["Sensor"] | dict[str, "Sensor"]],  # noqa F821
     event_starts_after: datetime | None = None,
     event_ends_before: datetime | None = None,
     **override_chart_specs: dict,
@@ -513,12 +512,11 @@ def chart_for_multiple_sensors(
     )
 
     sensors_specs = []
-    for s in sensors_to_show:
+    for entry in sensors_to_show:
+        title = entry.get("title")
+        sensors = entry.get("sensors")
         # List the sensors that go into one row
-        if isinstance(s, list):
-            row_sensors: list["Sensor"] = s  # noqa F821
-        else:
-            row_sensors: list["Sensor"] = [s]  # noqa F821
+        row_sensors: list["Sensor"] = sensors  # noqa F821
 
         # Derive the unit that should be shown
         unit = determine_shared_unit(row_sensors)
@@ -601,14 +599,7 @@ def chart_for_multiple_sensors(
 
         # Layer the lines, rectangles and circles within one row, and filter by which sensors are represented in the row
         sensor_specs = {
-            "title": join_words_into_a_list(
-                [
-                    f"{capitalize(sensor.name)}"
-                    for sensor in row_sensors
-                    # the sensor type is already shown as the y-axis title (avoid redundant info)
-                    if sensor.name != sensor.sensor_type
-                ]
-            ),
+            "title": f"{capitalize(title)}" if title else None,
             "transform": [
                 {
                     "filter": {
diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py
index 3ba9330d..3f2b8efd 100644
--- a/flexmeasures/data/models/generic_assets.py
+++ b/flexmeasures/data/models/generic_assets.py
@@ -602,28 +602,50 @@ class GenericAsset(db.Model, AuthModelMixin):
         return bdf_dict
 
     @property
-    def sensors_to_show(self) -> list["Sensor" | list["Sensor"]]:  # noqa F821
-        """Sensors to show, as defined by the sensors_to_show attribute.
+    def sensors_to_show(
+        self,
+    ) -> list[dict[str, "Sensor"]]:  # noqa F821
+        """
+        Sensors to show, as defined by the sensors_to_show attribute.
+
+        Sensors to show are defined as a list of sensor IDs, which are set by the "sensors_to_show" field in the asset's "attributes" column.
+        Valid sensors either belong to the asset itself, to other assets in the same account, or to public assets.
+        In play mode, sensors from different accounts can be added.
 
-        Sensors to show are defined as a list of sensor ids, which
-        is set by the "sensors_to_show" field of the asset's "attributes" column.
-        Valid sensors either belong to the asset itself, to other assets in the same account,
-        or to public assets. In play mode, sensors from different accounts can be added.
+        Sensor IDs can be nested to denote that sensors should be 'shown together', for example, layered rather than vertically concatenated.
+        Additionally, each row of sensors can be accompanied by a title.
+        If no title is provided, `"title": None` will be assigned in the returned dictionary.
 
-        Sensor ids can be nested to denote that sensors should be 'shown together',
-        for example, layered rather than vertically concatenated.
-        How to interpret 'shown together' is technically left up to the function returning chart specs,
-        as are any restrictions regarding what sensors can be shown together, such as:
-        - whether they should share the same unit
-        - whether they should share the same name
-        - whether they should belong to different assets
+        How to interpret 'shown together' is technically left up to the function returning chart specifications, as are any restrictions regarding which sensors can be shown together, such as:
+        - Whether they should share the same unit
+        - Whether they should share the same name
+        - Whether they should belong to different assets
 
-        For example, this denotes showing sensors 42 and 44 together:
+        For example, this input denotes showing sensors 42 and 44 together:
 
             sensors_to_show = [40, 35, 41, [42, 44], 43, 45]
 
-        In case the field is missing, defaults to two of the asset's sensors,
-        which will be shown together (e.g. sharing the same y-axis) in case they share the same unit.
+        And this input denotes showing sensors 42 and 44 together with a custom title:
+
+            sensors_to_show = [
+                {"title": "Title 1", "sensor": 40},
+                {"title": "Title 2", "sensors": [41, 42]},
+                [43, 44], 45, 46
+            ]
+
+        In both cases, the returned format will contain sensor objects mapped to their respective sensor IDs, as follows:
+
+            [
+                {"title": "Title 1", "sensor": <Sensor object for sensor 40>},
+                {"title": "Title 2", "sensors": [<Sensor object for sensor 41>, <Sensor object for sensor 42>]},
+                {"title": None, "sensors": [<Sensor object for sensor 43>, <Sensor object for sensor 44>]},
+                {"title": None, "sensor": <Sensor object for sensor 45>},
+                {"title": None, "sensor": <Sensor object for sensor 46>}
+            ]
+
+        In case the `sensors_to_show` field is missing, it defaults to two of the asset's sensors. These will be shown together (e.g., sharing the same y-axis) if they share the same unit; otherwise, they will be shown separately.
+
+        Sensors are validated to ensure they are accessible by the user. If certain sensors are inaccessible, they will be excluded from the result, and a warning will be logged. The function only returns sensors that the user has permission to view.
         """
         if not self.has_attribute("sensors_to_show"):
             sensors_to_show = self.sensors[:2]
@@ -632,9 +654,19 @@ class GenericAsset(db.Model, AuthModelMixin):
                 and sensors_to_show[0].unit == sensors_to_show[1].unit
             ):
                 # Sensors are shown together (e.g. they can share the same y-axis)
-                return [sensors_to_show]
+                return [{"title": None, "sensors": sensors_to_show}]
             # Otherwise, show separately
-            return sensors_to_show
+            return [{"title": None, "sensors": [sensor]} for sensor in sensors_to_show]
+
+        sensor_ids_to_show = self.get_attribute("sensors_to_show")
+        # Import the schema for validation
+        from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
+
+        # Deserialize the sensor_ids_to_show using SensorsToShowSchema
+        standardized_sensors_to_show = SensorsToShowSchema().deserialize(
+            sensor_ids_to_show
+        )
+        sensor_id_allowlist = flatten_unique(standardized_sensors_to_show)
 
         # Only allow showing sensors from assets owned by the user's organization,
         # except in play mode, where any sensor may be shown
@@ -646,13 +678,12 @@ class GenericAsset(db.Model, AuthModelMixin):
 
         from flexmeasures.data.services.sensors import get_sensors
 
-        sensor_ids_to_show = self.get_attribute("sensors_to_show")
         accessible_sensor_map = {
             sensor.id: sensor
             for sensor in get_sensors(
                 account=accounts,
                 include_public_assets=True,
-                sensor_id_allowlist=flatten_unique(sensor_ids_to_show),
+                sensor_id_allowlist=sensor_id_allowlist,
             )
         }
 
@@ -660,24 +691,21 @@ class GenericAsset(db.Model, AuthModelMixin):
         sensors_to_show = []
         missed_sensor_ids = []
 
-        # we make sure to build in the order given by the sensors_to_show attribute, and with the same nesting
-        for s in sensor_ids_to_show:
-            if isinstance(s, list):
-                inaccessible = [sid for sid in s if sid not in accessible_sensor_map]
-                missed_sensor_ids.extend(inaccessible)
-                if len(inaccessible) < len(s):
-                    sensors_to_show.append(
-                        [
-                            accessible_sensor_map[sensor_id]
-                            for sensor_id in s
-                            if sensor_id in accessible_sensor_map
-                        ]
-                    )
-            else:
-                if s not in accessible_sensor_map:
-                    missed_sensor_ids.append(s)
-                else:
-                    sensors_to_show.append(accessible_sensor_map[s])
+        for entry in standardized_sensors_to_show:
+
+            title = entry.get("title")
+            sensors = entry.get("sensors")
+
+            accessible_sensors = [
+                accessible_sensor_map.get(sid)
+                for sid in sensors
+                if sid in accessible_sensor_map
+            ]
+            inaccessible = [sid for sid in sensors if sid not in accessible_sensor_map]
+            missed_sensor_ids.extend(inaccessible)
+            if accessible_sensors:
+                sensors_to_show.append({"title": title, "sensors": accessible_sensors})
+
         if missed_sensor_ids:
             current_app.logger.warning(
                 f"Cannot include sensor(s) {missed_sensor_ids} in sensors_to_show on asset {self}, as it is not accessible to user {current_user}."
diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py
index 2c612e04..b6777f2f 100644
--- a/flexmeasures/data/schemas/generic_assets.py
+++ b/flexmeasures/data/schemas/generic_assets.py
@@ -31,6 +31,85 @@ class JSON(fields.Field):
         return json.dumps(value)
 
 
+class SensorsToShowSchema(fields.Field):
+    """
+    Schema for validating and deserializing the `sensors_to_show` attribute of a GenericAsset.
+
+    The `sensors_to_show` attribute defines which sensors should be displayed for a particular asset.
+    It supports various input formats, which are standardized into a list of dictionaries, each containing
+    a `title` (optional) and a `sensors` list. The valid input formats include:
+
+    - A single sensor ID (int): `42` -> `{"title": None, "sensors": [42]}`
+    - A list of sensor IDs (list of ints): `[42, 43]` -> `{"title": None, "sensors": [42, 43]}`
+    - A dictionary with a title and sensor: `{"title": "Temperature", "sensor": 42}` -> `{"title": "Temperature", "sensors": [42]}`
+    - A dictionary with a title and sensors: `{"title": "Pressure", "sensors": [42, 43]}`
+
+    Validation ensures that:
+    - The input is either a list, integer, or dictionary.
+    - If the input is a dictionary, it must contain either `sensor` (int) or `sensors` (list of ints).
+    - All sensor IDs must be valid integers.
+
+    Example Input:
+    - `[{"title": "Test", "sensors": [1, 2]}, {"title": None, "sensors": [3, 4]}, 5]`
+
+    Example Output (Standardized):
+    - `[{"title": "Test", "sensors": [1, 2]}, {"title": None, "sensors": [3, 4]}, {"title": None, "sensors": [5]}]`
+    """
+
+    def deserialize(self, value, **kwargs) -> list:
+        """
+        Validate and deserialize the input value.
+        """
+        try:
+            # Parse JSON if input is a string
+            if isinstance(value, str):
+                value = json.loads(value)
+
+            # Ensure value is a list
+            if not isinstance(value, list):
+                raise ValidationError("sensors_to_show should be a list.")
+
+            # Standardize each item in the list
+            return [self._standardize_item(item) for item in value]
+        except json.JSONDecodeError:
+            raise ValidationError("Invalid JSON string.")
+
+    def _standardize_item(self, item) -> dict:
+        """
+        Standardize different input formats to a consistent dictionary format.
+        """
+        if isinstance(item, int):
+            return {"title": None, "sensors": [item]}
+        elif isinstance(item, list):
+            if not all(isinstance(sensor_id, int) for sensor_id in item):
+                raise ValidationError(
+                    "All elements in a list within 'sensors_to_show' must be integers."
+                )
+            return {"title": None, "sensors": item}
+        elif isinstance(item, dict):
+            title = item.get("title", None)
+            if "sensor" in item:
+                sensor = item["sensor"]
+                if not isinstance(sensor, int):
+                    raise ValidationError("'sensor' value must be an integer.")
+                return {"title": title, "sensors": [sensor]}
+            elif "sensors" in item:
+                sensors = item["sensors"]
+                if not isinstance(sensors, list) or not all(
+                    isinstance(sensor_id, int) for sensor_id in sensors
+                ):
+                    raise ValidationError("'sensors' value must be a list of integers.")
+                return {"title": title, "sensors": sensors}
+            else:
+                raise ValidationError(
+                    "Dictionary must contain either 'sensor' or 'sensors' key."
+                )
+        else:
+            raise ValidationError(
+                "Invalid item type in 'sensors_to_show'. Expected int, list, or dict."
+            )
+
+
 class GenericAssetSchema(ma.SQLAlchemySchema):
     """
     GenericAsset schema, with validations.
@@ -122,20 +201,18 @@ class GenericAssetSchema(ma.SQLAlchemySchema):
     def validate_attributes(self, attributes: dict):
         sensors_to_show = attributes.get("sensors_to_show", [])
 
-        # Check type
-        if not isinstance(sensors_to_show, list):
-            raise ValidationError("sensors_to_show should be a list.")
-        for sensor_listing in sensors_to_show:
-            if not isinstance(sensor_listing, (int, list)):
-                raise ValidationError(
-                    "sensors_to_show should only contain sensor IDs (integers) or lists thereof."
-                )
-            if isinstance(sensor_listing, list):
-                for sensor_id in sensor_listing:
-                    if not isinstance(sensor_id, int):
-                        raise ValidationError(
-                            "sensors_to_show should only contain sensor IDs (integers) or lists thereof."
-                        )
+        if sensors_to_show:
+
+            # Use SensorsToShowSchema to validate and deserialize sensors_to_show
+            sensors_to_show_schema = SensorsToShowSchema()
+
+            standardized_sensors = sensors_to_show_schema.deserialize(sensors_to_show)
+            unique_sensor_ids = flatten_unique(standardized_sensors)
+            # Check whether IDs represent accessible sensors
+            from flexmeasures.data.schemas import SensorIdField
+
+            for sensor_id in unique_sensor_ids:
+                SensorIdField().deserialize(sensor_id)
 
         # Check whether IDs represent accessible sensors
         from flexmeasures.data.schemas import SensorIdField
diff --git a/flexmeasures/utils/coding_utils.py b/flexmeasures/utils/coding_utils.py
index a5286a3d..739b12b5 100644
--- a/flexmeasures/utils/coding_utils.py
+++ b/flexmeasures/utils/coding_utils.py
@@ -1,5 +1,4 @@
 """ Various coding utils (e.g. around function decoration) """
-
 from __future__ import annotations
 
 import functools
@@ -83,6 +82,8 @@ def flatten_unique(nested_list_of_objects: list) -> list:
     for s in nested_list_of_objects:
         if isinstance(s, list):
             all_objects.extend(s)
+        elif isinstance(s, dict):
+            all_objects.extend(s["sensors"])
         else:
             all_objects.append(s)
     return list(dict.fromkeys(all_objects).keys())


Test patch:
diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py
index 0195120a..6b23aa53 100644
--- a/flexmeasures/api/v3_0/tests/test_assets_api.py
+++ b/flexmeasures/api/v3_0/tests/test_assets_api.py
@@ -203,7 +203,7 @@ def test_alter_an_asset(
     print(f"Editing Response: {asset_edit_response.json}")
     assert asset_edit_response.status_code == 200
 
-    audit_log_event = f"Updated asset '{prosumer_asset.name}': {prosumer_asset.id} fields: Field name: name, Old value: {name}, New value: other; Field name: latitude, Old value: {latitude}, New value: 11.1"
+    audit_log_event = f"Updated asset '{prosumer_asset.name}': {prosumer_asset.id} fields: Field: name, From: {name}, To: other; Field: latitude, From: {latitude}, To: 11.1"
     assert db.session.execute(
         select(AssetAuditLog).filter_by(
             event=audit_log_event,
@@ -224,11 +224,11 @@ def test_alter_an_asset(
         ('{"sensors_to_show": [1, [0, 2]]}', "No sensor found"),  # no sensor with ID 0
         (
             '{"sensors_to_show": [1, [2, [3, 4]]]}',
-            "should only contain",
+            "All elements in a list within 'sensors_to_show' must be integers.",
         ),  # nesting level max 1
         (
             '{"sensors_to_show": [1, "2"]}',
-            "should only contain",
+            "Invalid item type in 'sensors_to_show'. Expected int, list, or dict.",
         ),  # non-integer sensor ID
     ],
 )
diff --git a/flexmeasures/cli/tests/test_data_edit.py b/flexmeasures/cli/tests/test_data_edit.py
index dbaa2ad7..7c5bf9e6 100644
--- a/flexmeasures/cli/tests/test_data_edit.py
+++ b/flexmeasures/cli/tests/test_data_edit.py
@@ -28,7 +28,7 @@ def test_add_one_sensor_attribute(app, db, setup_markets):
     result = runner.invoke(edit_attribute, to_flags(cli_input))
     assert result.exit_code == 0 and "Success" in result.output, result.exception
 
-    event = f"Updated sensor '{sensor.name}': {sensor.id} attribute 'some new attribute' to 3.0 from None"
+    event = f"Updated sensor '{sensor.name}': {sensor.id} Attr 'some new attribute' To 3.0 From None"
     assert db.session.execute(
         select(AssetAuditLog).filter_by(
             affected_asset_id=sensor.generic_asset_id,
@@ -61,7 +61,7 @@ def test_update_one_asset_attribute(app, db, setup_generic_assets):
     result = runner.invoke(edit_attribute, to_flags(cli_input))
     assert result.exit_code == 0 and "Success" in result.output, result.exception
 
-    event = f"Updated asset '{asset.name}': {asset.id} attribute 'some-attribute' to some-new-value from some-value"
+    event = f"Updated asset '{asset.name}': {asset.id} Attr 'some-attribute' To some-new-value From some-value"
     assert db.session.execute(
         select(AssetAuditLog).filter_by(
             affected_asset_id=asset.id,
diff --git a/flexmeasures/data/tests/test_SensorsToShowSchema.py b/flexmeasures/data/tests/test_SensorsToShowSchema.py
new file mode 100644
index 00000000..63cfe59d
--- /dev/null
+++ b/flexmeasures/data/tests/test_SensorsToShowSchema.py
@@ -0,0 +1,87 @@
+import pytest
+from marshmallow import ValidationError
+
+from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
+
+
+def test_single_sensor_id():
+    schema = SensorsToShowSchema()
+    input_value = [42]
+    expected_output = [{"title": None, "sensors": [42]}]
+    assert schema.deserialize(input_value) == expected_output
+
+
+def test_list_of_sensor_ids():
+    schema = SensorsToShowSchema()
+    input_value = [42, 43]
+    expected_output = [
+        {"title": None, "sensors": [42]},
+        {"title": None, "sensors": [43]},
+    ]
+    assert schema.deserialize(input_value) == expected_output
+
+
+def test_dict_with_title_and_single_sensor():
+    schema = SensorsToShowSchema()
+    input_value = [{"title": "Temperature", "sensor": 42}]
+    expected_output = [{"title": "Temperature", "sensors": [42]}]
+    assert schema.deserialize(input_value) == expected_output
+
+
+def test_dict_with_title_and_multiple_sensors():
+    schema = SensorsToShowSchema()
+    input_value = [{"title": "Pressure", "sensors": [42, 43]}]
+    expected_output = [{"title": "Pressure", "sensors": [42, 43]}]
+    assert schema.deserialize(input_value) == expected_output
+
+
+def test_invalid_sensor_string_input():
+    schema = SensorsToShowSchema()
+    with pytest.raises(ValidationError, match="Invalid JSON string."):
+        schema.deserialize("invalid_string")
+
+
+def test_invalid_sensor_in_list():
+    schema = SensorsToShowSchema()
+    input_value = [{"title": "Test", "sensors": [42, "invalid"]}]
+    with pytest.raises(
+        ValidationError, match="'sensors' value must be a list of integers."
+    ):
+        schema.deserialize(input_value)
+
+
+def test_invalid_sensor_dict_without_sensors_key():
+    schema = SensorsToShowSchema()
+    input_value = [{"title": "Test", "something_else": 42}]
+    with pytest.raises(
+        ValidationError,
+        match="Dictionary must contain either 'sensor' or 'sensors' key.",
+    ):
+        schema.deserialize(input_value)
+
+
+def test_mixed_valid_inputs():
+    schema = SensorsToShowSchema()
+    input_value = [
+        {"title": "Test", "sensors": [1, 2]},
+        {"title": None, "sensors": [3, 4]},
+        5,
+    ]
+    expected_output = [
+        {"title": "Test", "sensors": [1, 2]},
+        {"title": None, "sensors": [3, 4]},
+        {"title": None, "sensors": [5]},
+    ]
+    assert schema.deserialize(input_value) == expected_output
+
+
+def test_string_json_input():
+    schema = SensorsToShowSchema()
+    input_value = (
+        '[{"title": "Test", "sensors": [1, 2]}, {"title": "Test2", "sensors": [3]}]'
+    )
+    expected_output = [
+        {"title": "Test", "sensors": [1, 2]},
+        {"title": "Test2", "sensors": [3]},
+    ]
+    assert schema.deserialize(input_value) == expected_output